- 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.
261 lines
10 KiB
C#
261 lines
10 KiB
C#
using System.Collections.Generic;
|
|
using System.ComponentModel.DataAnnotations;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using StellaOps.Scheduler.Models;
|
|
using StellaOps.Scheduler.WebService.GraphJobs;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.Scheduler.WebService.Tests;
|
|
|
|
public sealed class GraphJobServiceTests
|
|
{
|
|
private static readonly DateTimeOffset FixedTime = new(2025, 11, 4, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
[Fact]
|
|
public async Task CompleteBuildJob_PersistsMetadataAndPublishesOnce()
|
|
{
|
|
var store = new TrackingGraphJobStore();
|
|
var initial = CreateBuildJob();
|
|
await store.AddAsync(initial, CancellationToken.None);
|
|
|
|
var clock = new FixedClock(FixedTime);
|
|
var publisher = new RecordingPublisher();
|
|
var webhook = new RecordingWebhookClient();
|
|
var service = new GraphJobService(store, clock, publisher, webhook);
|
|
|
|
var request = new GraphJobCompletionRequest
|
|
{
|
|
JobId = initial.Id,
|
|
JobType = GraphJobQueryType.Build,
|
|
Status = GraphJobStatus.Completed,
|
|
OccurredAt = FixedTime,
|
|
GraphSnapshotId = "graph_snap_final ",
|
|
ResultUri = "oras://cartographer/bundle ",
|
|
CorrelationId = "corr-123 "
|
|
};
|
|
|
|
var response = await service.CompleteJobAsync(initial.TenantId, request, CancellationToken.None);
|
|
|
|
Assert.Equal(GraphJobStatus.Completed, response.Status);
|
|
Assert.Equal(1, store.BuildUpdateCount);
|
|
Assert.Single(publisher.Notifications);
|
|
Assert.Single(webhook.Notifications);
|
|
|
|
var stored = await store.GetBuildJobAsync(initial.TenantId, initial.Id, CancellationToken.None);
|
|
Assert.NotNull(stored);
|
|
Assert.Equal("graph_snap_final", stored!.GraphSnapshotId);
|
|
Assert.Equal("corr-123", stored.CorrelationId);
|
|
Assert.True(stored.Metadata.TryGetValue("resultUri", out var resultUri));
|
|
Assert.Equal("oras://cartographer/bundle", resultUri);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CompleteBuildJob_IsIdempotentWhenAlreadyCompleted()
|
|
{
|
|
var store = new TrackingGraphJobStore();
|
|
var initial = CreateBuildJob();
|
|
await store.AddAsync(initial, CancellationToken.None);
|
|
|
|
var clock = new FixedClock(FixedTime);
|
|
var publisher = new RecordingPublisher();
|
|
var webhook = new RecordingWebhookClient();
|
|
var service = new GraphJobService(store, clock, publisher, webhook);
|
|
|
|
var request = new GraphJobCompletionRequest
|
|
{
|
|
JobId = initial.Id,
|
|
JobType = GraphJobQueryType.Build,
|
|
Status = GraphJobStatus.Completed,
|
|
OccurredAt = FixedTime,
|
|
GraphSnapshotId = "graph_snap_final",
|
|
ResultUri = "oras://cartographer/bundle",
|
|
CorrelationId = "corr-123"
|
|
};
|
|
|
|
await service.CompleteJobAsync(initial.TenantId, request, CancellationToken.None);
|
|
var updateCountAfterFirst = store.BuildUpdateCount;
|
|
|
|
var secondResponse = await service.CompleteJobAsync(initial.TenantId, request, CancellationToken.None);
|
|
|
|
Assert.Equal(GraphJobStatus.Completed, secondResponse.Status);
|
|
Assert.Equal(updateCountAfterFirst, store.BuildUpdateCount);
|
|
Assert.Single(publisher.Notifications);
|
|
Assert.Single(webhook.Notifications);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CompleteBuildJob_UpdatesResultUriWithoutReemittingEvent()
|
|
{
|
|
var store = new TrackingGraphJobStore();
|
|
var initial = CreateBuildJob();
|
|
await store.AddAsync(initial, CancellationToken.None);
|
|
|
|
var clock = new FixedClock(FixedTime);
|
|
var publisher = new RecordingPublisher();
|
|
var webhook = new RecordingWebhookClient();
|
|
var service = new GraphJobService(store, clock, publisher, webhook);
|
|
|
|
var firstRequest = new GraphJobCompletionRequest
|
|
{
|
|
JobId = initial.Id,
|
|
JobType = GraphJobQueryType.Build,
|
|
Status = GraphJobStatus.Completed,
|
|
OccurredAt = FixedTime,
|
|
GraphSnapshotId = "graph_snap_final",
|
|
ResultUri = null,
|
|
CorrelationId = "corr-123"
|
|
};
|
|
|
|
await service.CompleteJobAsync(initial.TenantId, firstRequest, CancellationToken.None);
|
|
Assert.Equal(1, store.BuildUpdateCount);
|
|
Assert.Single(publisher.Notifications);
|
|
Assert.Single(webhook.Notifications);
|
|
|
|
var secondRequest = firstRequest with
|
|
{
|
|
ResultUri = "oras://cartographer/bundle-v2",
|
|
OccurredAt = FixedTime.AddSeconds(30)
|
|
};
|
|
|
|
var response = await service.CompleteJobAsync(initial.TenantId, secondRequest, CancellationToken.None);
|
|
|
|
Assert.Equal(GraphJobStatus.Completed, response.Status);
|
|
Assert.Equal(2, store.BuildUpdateCount);
|
|
Assert.Single(publisher.Notifications);
|
|
Assert.Single(webhook.Notifications);
|
|
|
|
var stored = await store.GetBuildJobAsync(initial.TenantId, initial.Id, CancellationToken.None);
|
|
Assert.NotNull(stored);
|
|
Assert.True(stored!.Metadata.TryGetValue("resultUri", out var resultUri));
|
|
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);
|
|
return new GraphBuildJob(
|
|
id: "gbj_test",
|
|
tenantId: "tenant-alpha",
|
|
sbomId: "sbom-alpha",
|
|
sbomVersionId: "sbom-alpha-v1",
|
|
sbomDigest: digest,
|
|
status: GraphJobStatus.Pending,
|
|
trigger: GraphBuildJobTrigger.SbomVersion,
|
|
createdAt: FixedTime,
|
|
metadata: null);
|
|
}
|
|
|
|
private sealed class TrackingGraphJobStore : IGraphJobStore
|
|
{
|
|
private readonly InMemoryGraphJobStore _inner = new();
|
|
|
|
public int BuildUpdateCount { get; private set; }
|
|
|
|
public int OverlayUpdateCount { get; private set; }
|
|
|
|
public ValueTask<GraphBuildJob> AddAsync(GraphBuildJob job, CancellationToken cancellationToken)
|
|
=> _inner.AddAsync(job, cancellationToken);
|
|
|
|
public ValueTask<GraphOverlayJob> AddAsync(GraphOverlayJob job, CancellationToken cancellationToken)
|
|
=> _inner.AddAsync(job, cancellationToken);
|
|
|
|
public ValueTask<GraphJobCollection> GetJobsAsync(string tenantId, GraphJobQuery query, CancellationToken cancellationToken)
|
|
=> _inner.GetJobsAsync(tenantId, query, cancellationToken);
|
|
|
|
public ValueTask<GraphBuildJob?> GetBuildJobAsync(string tenantId, string jobId, CancellationToken cancellationToken)
|
|
=> _inner.GetBuildJobAsync(tenantId, jobId, cancellationToken);
|
|
|
|
public ValueTask<GraphOverlayJob?> GetOverlayJobAsync(string tenantId, string jobId, CancellationToken cancellationToken)
|
|
=> _inner.GetOverlayJobAsync(tenantId, jobId, cancellationToken);
|
|
|
|
public async ValueTask<GraphJobUpdateResult<GraphBuildJob>> UpdateAsync(GraphBuildJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken)
|
|
{
|
|
BuildUpdateCount++;
|
|
return await _inner.UpdateAsync(job, expectedStatus, cancellationToken);
|
|
}
|
|
|
|
public async ValueTask<GraphJobUpdateResult<GraphOverlayJob>> UpdateAsync(GraphOverlayJob job, GraphJobStatus expectedStatus, CancellationToken cancellationToken)
|
|
{
|
|
OverlayUpdateCount++;
|
|
return await _inner.UpdateAsync(job, expectedStatus, cancellationToken);
|
|
}
|
|
|
|
public ValueTask<IReadOnlyCollection<GraphOverlayJob>> GetOverlayJobsAsync(string tenantId, CancellationToken cancellationToken)
|
|
=> _inner.GetOverlayJobsAsync(tenantId, cancellationToken);
|
|
}
|
|
|
|
private sealed class RecordingPublisher : IGraphJobCompletionPublisher
|
|
{
|
|
public List<GraphJobCompletionNotification> Notifications { get; } = new();
|
|
|
|
public Task PublishAsync(GraphJobCompletionNotification notification, CancellationToken cancellationToken)
|
|
{
|
|
Notifications.Add(notification);
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private sealed class RecordingWebhookClient : ICartographerWebhookClient
|
|
{
|
|
public List<GraphJobCompletionNotification> Notifications { get; } = new();
|
|
|
|
public Task NotifyAsync(GraphJobCompletionNotification notification, CancellationToken cancellationToken)
|
|
{
|
|
Notifications.Add(notification);
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private sealed class FixedClock : ISystemClock
|
|
{
|
|
public FixedClock(DateTimeOffset utcNow)
|
|
{
|
|
UtcNow = utcNow;
|
|
}
|
|
|
|
public DateTimeOffset UtcNow { get; set; }
|
|
}
|
|
}
|