Add tests for SBOM generation determinism across multiple formats
- Created `StellaOps.TestKit.Tests` project for unit tests related to determinism. - Implemented `DeterminismManifestTests` to validate deterministic output for canonical bytes and strings, file read/write operations, and error handling for invalid schema versions. - Added `SbomDeterminismTests` to ensure identical inputs produce consistent SBOMs across SPDX 3.0.1 and CycloneDX 1.6/1.7 formats, including parallel execution tests. - Updated project references in `StellaOps.Integration.Determinism` to include the new determinism testing library.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using StellaOps.Cryptography.Digests;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.GraphJobs;
|
||||
@@ -457,26 +458,16 @@ internal sealed class GraphJobService : IGraphJobService
|
||||
|
||||
private static string NormalizeDigest(string value)
|
||||
{
|
||||
var text = value.Trim();
|
||||
if (!text.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
try
|
||||
{
|
||||
throw new ValidationException("sbomDigest must start with 'sha256:'.");
|
||||
return Sha256Digest.Normalize(value, requirePrefix: true, parameterName: "sbomDigest");
|
||||
}
|
||||
|
||||
var digest = text[7..];
|
||||
if (digest.Length != 64 || !digest.All(IsHex))
|
||||
catch (Exception ex) when (ex is ArgumentException or FormatException)
|
||||
{
|
||||
throw new ValidationException("sbomDigest must contain 64 hexadecimal characters.");
|
||||
throw new ValidationException(ex.Message);
|
||||
}
|
||||
|
||||
return $"sha256:{digest.ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static bool IsHex(char c)
|
||||
=> (c >= '0' && c <= '9') ||
|
||||
(c >= 'a' && c <= 'f') ||
|
||||
(c >= 'A' && c <= 'F');
|
||||
|
||||
private static ImmutableSortedDictionary<string, string> MergeMetadata(ImmutableSortedDictionary<string, string> existing, string? resultUri)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(resultUri))
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scheduler.ImpactIndex/StellaOps.Scheduler.ImpactIndex.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scheduler.Queue/StellaOps.Scheduler.Queue.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Scheduler.Storage.Postgres/StellaOps.Scheduler.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"schema": "scheduler-impact-index@1",
|
||||
"generatedAt": "2025-10-01T00:00:00Z",
|
||||
"image": {
|
||||
"repository": "registry.stellaops.test/team/sample-service",
|
||||
"digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"tag": "1.0.0"
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"purl": "pkg:docker/sample-service@1.0.0",
|
||||
"usage": [
|
||||
"runtime"
|
||||
]
|
||||
},
|
||||
{
|
||||
"purl": "pkg:pypi/requests@2.31.0",
|
||||
"usage": [
|
||||
"usedByEntrypoint"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,11 +3,11 @@ using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Collections.Special;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Scheduler.ImpactIndex.Ingestion;
|
||||
using StellaOps.Scheduler.Models;
|
||||
|
||||
@@ -18,6 +18,7 @@ namespace StellaOps.Scheduler.ImpactIndex;
|
||||
/// </summary>
|
||||
public sealed class RoaringImpactIndex : IImpactIndex
|
||||
{
|
||||
private static readonly ICryptoHash Hash = CryptoHashFactory.CreateDefault();
|
||||
private readonly object _gate = new();
|
||||
|
||||
private readonly Dictionary<string, int> _imageIds = new(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -570,8 +571,8 @@ public sealed class RoaringImpactIndex : IImpactIndex
|
||||
AppendMap(contains);
|
||||
AppendMap(usedBy);
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
|
||||
return "snap-" + Convert.ToHexString(hash).ToLowerInvariant();
|
||||
var hashHex = Hash.ComputeHashHex(Encoding.UTF8.GetBytes(builder.ToString()), HashAlgorithms.Sha256);
|
||||
return "snap-" + hashHex;
|
||||
}
|
||||
|
||||
private static bool MatchesTagPattern(string tag, string pattern)
|
||||
@@ -620,7 +621,7 @@ public sealed class RoaringImpactIndex : IImpactIndex
|
||||
|
||||
private static int ComputeDeterministicId(string digest)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(digest));
|
||||
var bytes = Hash.ComputeHash(Encoding.UTF8.GetBytes(digest), HashAlgorithms.Sha256);
|
||||
for (var offset = 0; offset <= bytes.Length - sizeof(int); offset += sizeof(int))
|
||||
{
|
||||
var value = BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(offset, sizeof(int))) & int.MaxValue;
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.Scheduler.Models/StellaOps.Scheduler.Models.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Fixtures\**\*.json" />
|
||||
<EmbeddedResource Include="..\..\samples\scanner\images\**\bom-index.json"
|
||||
<EmbeddedResource Include="..\..\..\..\samples\scanner\images\**\bom-index.json"
|
||||
Link="Fixtures\%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
|
||||
@@ -231,6 +231,7 @@ public sealed class RoaringImpactIndexTests
|
||||
await index.RemoveAsync(digest1);
|
||||
|
||||
var snapshot = await index.CreateSnapshotAsync();
|
||||
snapshot.SnapshotId.Should().MatchRegex("^snap-[0-9a-f]{64}$");
|
||||
|
||||
var restored = new RoaringImpactIndex(NullLogger<RoaringImpactIndex>.Instance);
|
||||
await restored.RestoreSnapshotAsync(snapshot);
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SchedulerPostgresFixture.cs
|
||||
// Sprint: SPRINT_5100_0007_0004_storage_harness
|
||||
// Task: STOR-HARNESS-011
|
||||
// Description: Scheduler PostgreSQL test fixture using TestKit
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Reflection;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Testing;
|
||||
using StellaOps.Scheduler.Storage.Postgres;
|
||||
using Xunit;
|
||||
|
||||
// Type aliases to disambiguate TestKit and Infrastructure.Postgres.Testing fixtures
|
||||
using TestKitPostgresFixture = StellaOps.TestKit.Fixtures.PostgresFixture;
|
||||
using TestKitPostgresIsolationMode = StellaOps.TestKit.Fixtures.PostgresIsolationMode;
|
||||
|
||||
namespace StellaOps.Scheduler.Storage.Postgres.Tests;
|
||||
|
||||
/// <summary>
|
||||
@@ -65,3 +76,68 @@ public sealed class SchedulerPostgresCollection : ICollectionFixture<SchedulerPo
|
||||
{
|
||||
public const string Name = "SchedulerPostgres";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TestKit-based PostgreSQL fixture for Scheduler storage tests.
|
||||
/// Uses TestKit's PostgresFixture for enhanced isolation modes.
|
||||
/// </summary>
|
||||
public sealed class SchedulerTestKitPostgresFixture : IAsyncLifetime
|
||||
{
|
||||
private TestKitPostgresFixture _fixture = null!;
|
||||
private Assembly MigrationAssembly => typeof(SchedulerDataSource).Assembly;
|
||||
|
||||
public TestKitPostgresFixture Fixture => _fixture;
|
||||
public string ConnectionString => _fixture.ConnectionString;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_fixture = new TestKitPostgresFixture(TestKitPostgresIsolationMode.Truncation);
|
||||
await _fixture.InitializeAsync();
|
||||
await _fixture.ApplyMigrationsFromAssemblyAsync(MigrationAssembly);
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => _fixture.DisposeAsync();
|
||||
|
||||
public async Task TruncateAllTablesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync(cancellationToken);
|
||||
|
||||
// Scheduler migrations create the canonical `scheduler.*` schema explicitly
|
||||
await using var connection = new NpgsqlConnection(ConnectionString);
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string listTablesSql = """
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'scheduler'
|
||||
AND table_type = 'BASE TABLE';
|
||||
""";
|
||||
|
||||
var tables = new List<string>();
|
||||
await using (var command = new NpgsqlCommand(listTablesSql, connection))
|
||||
await using (var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
tables.Add(reader.GetString(0));
|
||||
}
|
||||
}
|
||||
|
||||
if (tables.Count > 0)
|
||||
{
|
||||
var qualified = tables.Select(static t => $"scheduler.\"{t}\"");
|
||||
var truncateSql = $"TRUNCATE TABLE {string.Join(", ", qualified)} RESTART IDENTITY CASCADE;";
|
||||
await using var truncateCommand = new NpgsqlCommand(truncateSql, connection);
|
||||
await truncateCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition for Scheduler TestKit PostgreSQL tests.
|
||||
/// </summary>
|
||||
[CollectionDefinition(SchedulerTestKitPostgresCollection.Name)]
|
||||
public sealed class SchedulerTestKitPostgresCollection : ICollectionFixture<SchedulerTestKitPostgresFixture>
|
||||
{
|
||||
public const string Name = "SchedulerTestKitPostgres";
|
||||
}
|
||||
|
||||
@@ -18,6 +18,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Scheduler.Storage.Postgres\StellaOps.Scheduler.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scheduler.Models;
|
||||
@@ -130,6 +131,47 @@ public sealed class GraphJobServiceTests
|
||||
Assert.Equal("oras://cartographer/bundle-v2", resultUri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBuildJob_NormalizesSbomDigest()
|
||||
{
|
||||
var store = new TrackingGraphJobStore();
|
||||
var clock = new FixedClock(FixedTime);
|
||||
var publisher = new RecordingPublisher();
|
||||
var webhook = new RecordingWebhookClient();
|
||||
var service = new GraphJobService(store, clock, publisher, webhook);
|
||||
|
||||
var request = new GraphBuildJobRequest
|
||||
{
|
||||
SbomId = "sbom-alpha",
|
||||
SbomVersionId = "sbom-alpha-v1",
|
||||
SbomDigest = " SHA256:" + new string('A', 64) + " ",
|
||||
};
|
||||
|
||||
var created = await service.CreateBuildJobAsync("tenant-alpha", request, CancellationToken.None);
|
||||
Assert.Equal("sha256:" + new string('a', 64), created.SbomDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBuildJob_RejectsDigestWithoutPrefix()
|
||||
{
|
||||
var store = new TrackingGraphJobStore();
|
||||
var clock = new FixedClock(FixedTime);
|
||||
var publisher = new RecordingPublisher();
|
||||
var webhook = new RecordingWebhookClient();
|
||||
var service = new GraphJobService(store, clock, publisher, webhook);
|
||||
|
||||
var request = new GraphBuildJobRequest
|
||||
{
|
||||
SbomId = "sbom-alpha",
|
||||
SbomVersionId = "sbom-alpha-v1",
|
||||
SbomDigest = new string('a', 64),
|
||||
};
|
||||
|
||||
var ex = await Assert.ThrowsAsync<ValidationException>(
|
||||
async () => await service.CreateBuildJobAsync("tenant-alpha", request, CancellationToken.None));
|
||||
Assert.Contains("sha256:", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static GraphBuildJob CreateBuildJob()
|
||||
{
|
||||
var digest = "sha256:" + new string('a', 64);
|
||||
|
||||
Reference in New Issue
Block a user