doctor enhancements, setup, enhancements, ui functionality and design consolidation and , test projects fixes , product advisory attestation/rekor and delta verfications enhancements
This commit is contained in:
@@ -15,7 +15,7 @@ public sealed class HeartbeatTimeoutMonitor : IHostedService, IDisposable
|
||||
private readonly ILogger<HeartbeatTimeoutMonitor> _logger;
|
||||
private readonly TimeSpan _checkInterval;
|
||||
private readonly TimeSpan _heartbeatTimeout;
|
||||
private Timer? _timer;
|
||||
private ITimer? _timer;
|
||||
|
||||
public HeartbeatTimeoutMonitor(
|
||||
IAgentManager agentManager,
|
||||
@@ -33,7 +33,7 @@ public sealed class HeartbeatTimeoutMonitor : IHostedService, IDisposable
|
||||
|
||||
public Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
_timer = new Timer(
|
||||
_timer = _timeProvider.CreateTimer(
|
||||
CheckForTimeouts,
|
||||
null,
|
||||
TimeSpan.FromMinutes(1),
|
||||
@@ -49,7 +49,7 @@ public sealed class HeartbeatTimeoutMonitor : IHostedService, IDisposable
|
||||
|
||||
public Task StopAsync(CancellationToken ct)
|
||||
{
|
||||
_timer?.Change(Timeout.Infinite, 0);
|
||||
_timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
||||
_logger.LogInformation("Heartbeat timeout monitor stopped");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ReleaseStatusService.cs
|
||||
// Sprint: SPRINT_20260118_018_AirGap_router_integration
|
||||
// Task: TASK-018-008 - "Provable Release" Badge Integration
|
||||
// Description: Service for computing release provability status
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Release provability status.
|
||||
/// </summary>
|
||||
public enum ReleaseProvabilityStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// All provability requirements met: SBOM, DSSE, Rekor, Referrers, Gates.
|
||||
/// </summary>
|
||||
Provable,
|
||||
|
||||
/// <summary>
|
||||
/// Some provability requirements met, but not all.
|
||||
/// </summary>
|
||||
Partial,
|
||||
|
||||
/// <summary>
|
||||
/// No provability evidence available.
|
||||
/// </summary>
|
||||
Unprovable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual provability check result.
|
||||
/// </summary>
|
||||
public sealed record ProvabilityCheck
|
||||
{
|
||||
/// <summary>
|
||||
/// Check name (sbom, dsse, rekor, referrers, gates).
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the check passed.
|
||||
/// </summary>
|
||||
public bool Passed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Check status message.
|
||||
/// </summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional details (format, version, etc.).
|
||||
/// </summary>
|
||||
public string? Details { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Complete release status result.
|
||||
/// </summary>
|
||||
public sealed record ReleaseStatusResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The image/artifact being checked.
|
||||
/// </summary>
|
||||
public required string Image { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Image digest.
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overall provability status.
|
||||
/// </summary>
|
||||
public ReleaseProvabilityStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Individual check results.
|
||||
/// </summary>
|
||||
public ImmutableArray<ProvabilityCheck> Checks { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp of the status check.
|
||||
/// </summary>
|
||||
public DateTimeOffset CheckedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of checks passed.
|
||||
/// </summary>
|
||||
public int PassedCount => Checks.Count(c => c.Passed);
|
||||
|
||||
/// <summary>
|
||||
/// Total number of checks.
|
||||
/// </summary>
|
||||
public int TotalCount => Checks.Length;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM status details.
|
||||
/// </summary>
|
||||
public sealed record SbomStatus
|
||||
{
|
||||
public bool Exists { get; init; }
|
||||
public string? Format { get; init; }
|
||||
public string? Version { get; init; }
|
||||
public string? Digest { get; init; }
|
||||
public bool IsDeterministic { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE status details.
|
||||
/// </summary>
|
||||
public sealed record DsseStatus
|
||||
{
|
||||
public bool Exists { get; init; }
|
||||
public string? SignerKey { get; init; }
|
||||
public string? Algorithm { get; init; }
|
||||
public bool IsValid { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rekor status details.
|
||||
/// </summary>
|
||||
public sealed record RekorStatus
|
||||
{
|
||||
public bool Exists { get; init; }
|
||||
public long? LogIndex { get; init; }
|
||||
public DateTimeOffset? IntegratedAt { get; init; }
|
||||
public bool ProofVerified { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OCI referrers status.
|
||||
/// </summary>
|
||||
public sealed record ReferrersStatus
|
||||
{
|
||||
public bool Exists { get; init; }
|
||||
public int Count { get; init; }
|
||||
public ImmutableArray<string> Types { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gates status.
|
||||
/// </summary>
|
||||
public sealed record GatesStatus
|
||||
{
|
||||
public int Passed { get; init; }
|
||||
public int Total { get; init; }
|
||||
public ImmutableArray<string> FailedGates { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service interface for computing release provability.
|
||||
/// </summary>
|
||||
public interface IReleaseStatusService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the provability status for an image.
|
||||
/// </summary>
|
||||
Task<ReleaseStatusResult> GetStatusAsync(string image, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets detailed SBOM status.
|
||||
/// </summary>
|
||||
Task<SbomStatus> GetSbomStatusAsync(string digest, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets detailed DSSE status.
|
||||
/// </summary>
|
||||
Task<DsseStatus> GetDsseStatusAsync(string digest, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets detailed Rekor status.
|
||||
/// </summary>
|
||||
Task<RekorStatus> GetRekorStatusAsync(string digest, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets OCI referrers status.
|
||||
/// </summary>
|
||||
Task<ReferrersStatus> GetReferrersStatusAsync(string digest, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets gate evaluation status.
|
||||
/// </summary>
|
||||
Task<GatesStatus> GetGatesStatusAsync(string digest, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of release status service.
|
||||
/// </summary>
|
||||
public sealed class ReleaseStatusService : IReleaseStatusService
|
||||
{
|
||||
private readonly ILogger<ReleaseStatusService> _logger;
|
||||
|
||||
public ReleaseStatusService(ILogger<ReleaseStatusService> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<ReleaseStatusResult> GetStatusAsync(string image, CancellationToken ct = default)
|
||||
{
|
||||
var (_, digest) = ParseImageReference(image);
|
||||
|
||||
_logger.LogInformation("Checking provability status for {Image}", image);
|
||||
|
||||
// Gather all statuses in parallel
|
||||
var sbomTask = GetSbomStatusAsync(digest, ct);
|
||||
var dsseTask = GetDsseStatusAsync(digest, ct);
|
||||
var rekorTask = GetRekorStatusAsync(digest, ct);
|
||||
var referrersTask = GetReferrersStatusAsync(digest, ct);
|
||||
var gatesTask = GetGatesStatusAsync(digest, ct);
|
||||
|
||||
await Task.WhenAll(sbomTask, dsseTask, rekorTask, referrersTask, gatesTask);
|
||||
|
||||
var sbom = await sbomTask;
|
||||
var dsse = await dsseTask;
|
||||
var rekor = await rekorTask;
|
||||
var referrers = await referrersTask;
|
||||
var gates = await gatesTask;
|
||||
|
||||
// Build checks
|
||||
var checks = ImmutableArray.CreateBuilder<ProvabilityCheck>();
|
||||
|
||||
// SBOM check
|
||||
checks.Add(new ProvabilityCheck
|
||||
{
|
||||
Name = "sbom",
|
||||
Passed = sbom.Exists && sbom.IsDeterministic,
|
||||
Message = sbom.Exists
|
||||
? $"{sbom.Format} {sbom.Version}"
|
||||
: "No SBOM found",
|
||||
Details = sbom.Digest
|
||||
});
|
||||
|
||||
// DSSE check
|
||||
checks.Add(new ProvabilityCheck
|
||||
{
|
||||
Name = "dsse",
|
||||
Passed = dsse.Exists && dsse.IsValid,
|
||||
Message = dsse.Exists
|
||||
? $"Signed by {dsse.SignerKey} ({dsse.Algorithm})"
|
||||
: "No DSSE envelope found",
|
||||
Details = dsse.SignerKey
|
||||
});
|
||||
|
||||
// Rekor check
|
||||
checks.Add(new ProvabilityCheck
|
||||
{
|
||||
Name = "rekor",
|
||||
Passed = rekor.Exists && rekor.ProofVerified,
|
||||
Message = rekor.Exists
|
||||
? $"Log index {rekor.LogIndex} @ {rekor.IntegratedAt:O}"
|
||||
: "No Rekor proof found",
|
||||
Details = rekor.LogIndex?.ToString()
|
||||
});
|
||||
|
||||
// Referrers check
|
||||
checks.Add(new ProvabilityCheck
|
||||
{
|
||||
Name = "referrers",
|
||||
Passed = referrers.Exists && referrers.Count > 0,
|
||||
Message = referrers.Exists
|
||||
? $"{referrers.Count} attestations attached"
|
||||
: "No OCI referrers found",
|
||||
Details = string.Join(", ", referrers.Types)
|
||||
});
|
||||
|
||||
// Gates check
|
||||
checks.Add(new ProvabilityCheck
|
||||
{
|
||||
Name = "gates",
|
||||
Passed = gates.Passed == gates.Total && gates.Total > 0,
|
||||
Message = gates.Total > 0
|
||||
? $"All {gates.Total} gates passed"
|
||||
: "No gates evaluated",
|
||||
Details = gates.FailedGates.Length > 0
|
||||
? $"Failed: {string.Join(", ", gates.FailedGates)}"
|
||||
: null
|
||||
});
|
||||
|
||||
var checksArray = checks.ToImmutable();
|
||||
var passedCount = checksArray.Count(c => c.Passed);
|
||||
|
||||
var status = passedCount == checksArray.Length
|
||||
? ReleaseProvabilityStatus.Provable
|
||||
: passedCount > 0
|
||||
? ReleaseProvabilityStatus.Partial
|
||||
: ReleaseProvabilityStatus.Unprovable;
|
||||
|
||||
return new ReleaseStatusResult
|
||||
{
|
||||
Image = image,
|
||||
Digest = digest,
|
||||
Status = status,
|
||||
Checks = checksArray,
|
||||
CheckedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<SbomStatus> GetSbomStatusAsync(string digest, CancellationToken ct = default)
|
||||
{
|
||||
await Task.Delay(10, ct); // Simulate lookup
|
||||
|
||||
// Simulate: SBOM exists for most images
|
||||
var exists = !digest.Contains("nosb");
|
||||
|
||||
return new SbomStatus
|
||||
{
|
||||
Exists = exists,
|
||||
Format = exists ? "CycloneDX" : null,
|
||||
Version = exists ? "1.6" : null,
|
||||
Digest = exists ? $"sha256:{Guid.NewGuid():N}" : null,
|
||||
IsDeterministic = exists
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<DsseStatus> GetDsseStatusAsync(string digest, CancellationToken ct = default)
|
||||
{
|
||||
await Task.Delay(10, ct);
|
||||
|
||||
var exists = !digest.Contains("nods");
|
||||
|
||||
return new DsseStatus
|
||||
{
|
||||
Exists = exists,
|
||||
SignerKey = exists ? "kms://projects/stella/keys/sbom-signer" : null,
|
||||
Algorithm = exists ? "ES256" : null,
|
||||
IsValid = exists
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<RekorStatus> GetRekorStatusAsync(string digest, CancellationToken ct = default)
|
||||
{
|
||||
await Task.Delay(10, ct);
|
||||
|
||||
var exists = !digest.Contains("nork");
|
||||
|
||||
return new RekorStatus
|
||||
{
|
||||
Exists = exists,
|
||||
LogIndex = exists ? Random.Shared.NextInt64(10_000_000, 20_000_000) : null,
|
||||
IntegratedAt = exists ? DateTimeOffset.UtcNow.AddHours(-2) : null,
|
||||
ProofVerified = exists
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ReferrersStatus> GetReferrersStatusAsync(string digest, CancellationToken ct = default)
|
||||
{
|
||||
await Task.Delay(10, ct);
|
||||
|
||||
var exists = !digest.Contains("noref");
|
||||
var count = exists ? Random.Shared.Next(2, 5) : 0;
|
||||
|
||||
return new ReferrersStatus
|
||||
{
|
||||
Exists = exists,
|
||||
Count = count,
|
||||
Types = exists
|
||||
? ["application/vnd.cyclonedx+json", "application/vnd.openvex+json"]
|
||||
: []
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<GatesStatus> GetGatesStatusAsync(string digest, CancellationToken ct = default)
|
||||
{
|
||||
await Task.Delay(10, ct);
|
||||
|
||||
var total = Random.Shared.Next(3, 8);
|
||||
var passed = digest.Contains("fail") ? total - 1 : total;
|
||||
|
||||
return new GatesStatus
|
||||
{
|
||||
Passed = passed,
|
||||
Total = total,
|
||||
FailedGates = passed < total ? ["vulnerability-scan"] : []
|
||||
};
|
||||
}
|
||||
|
||||
private static (string Repo, string Digest) ParseImageReference(string image)
|
||||
{
|
||||
var atIndex = image.IndexOf('@');
|
||||
if (atIndex < 0)
|
||||
{
|
||||
throw new ArgumentException("Image must include digest (@sha256:...)", nameof(image));
|
||||
}
|
||||
|
||||
return (image[..atIndex], image[(atIndex + 1)..]);
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,10 @@ public sealed class HealthCheckScheduler : IHostedService, IDisposable
|
||||
private readonly IEnvironmentService _environmentService;
|
||||
private readonly ITargetHealthChecker _healthChecker;
|
||||
private readonly ILogger<HealthCheckScheduler> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly TimeSpan _checkInterval;
|
||||
private readonly TimeSpan _initialDelay;
|
||||
private Timer? _timer;
|
||||
private ITimer? _timer;
|
||||
private bool _disposed;
|
||||
|
||||
public HealthCheckScheduler(
|
||||
@@ -24,6 +25,7 @@ public sealed class HealthCheckScheduler : IHostedService, IDisposable
|
||||
IEnvironmentService environmentService,
|
||||
ITargetHealthChecker healthChecker,
|
||||
ILogger<HealthCheckScheduler> logger,
|
||||
TimeProvider? timeProvider = null,
|
||||
TimeSpan? checkInterval = null,
|
||||
TimeSpan? initialDelay = null)
|
||||
{
|
||||
@@ -31,6 +33,7 @@ public sealed class HealthCheckScheduler : IHostedService, IDisposable
|
||||
_environmentService = environmentService ?? throw new ArgumentNullException(nameof(environmentService));
|
||||
_healthChecker = healthChecker ?? throw new ArgumentNullException(nameof(healthChecker));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_checkInterval = checkInterval ?? TimeSpan.FromMinutes(1);
|
||||
_initialDelay = initialDelay ?? TimeSpan.FromSeconds(30);
|
||||
}
|
||||
@@ -42,7 +45,7 @@ public sealed class HealthCheckScheduler : IHostedService, IDisposable
|
||||
"Health check scheduler starting (interval: {Interval}, initial delay: {InitialDelay})",
|
||||
_checkInterval, _initialDelay);
|
||||
|
||||
_timer = new Timer(
|
||||
_timer = _timeProvider.CreateTimer(
|
||||
DoHealthChecks,
|
||||
null,
|
||||
_initialDelay,
|
||||
@@ -55,7 +58,7 @@ public sealed class HealthCheckScheduler : IHostedService, IDisposable
|
||||
public Task StopAsync(CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("Health check scheduler stopping");
|
||||
_timer?.Change(Timeout.Infinite, 0);
|
||||
_timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ public sealed class SyncScheduler : IHostedService, IDisposable
|
||||
private readonly ILogger<SyncScheduler> _logger;
|
||||
private readonly TimeSpan _syncInterval;
|
||||
private readonly TimeSpan _initialDelay;
|
||||
private Timer? _timer;
|
||||
private ITimer? _timer;
|
||||
|
||||
public SyncScheduler(
|
||||
IInventorySyncService syncService,
|
||||
@@ -35,7 +35,7 @@ public sealed class SyncScheduler : IHostedService, IDisposable
|
||||
|
||||
public Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
_timer = new Timer(
|
||||
_timer = _timeProvider.CreateTimer(
|
||||
DoSync,
|
||||
null,
|
||||
_initialDelay,
|
||||
@@ -51,7 +51,7 @@ public sealed class SyncScheduler : IHostedService, IDisposable
|
||||
|
||||
public Task StopAsync(CancellationToken ct)
|
||||
{
|
||||
_timer?.Change(Timeout.Infinite, 0);
|
||||
_timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
||||
_logger.LogInformation("Inventory sync scheduler stopped");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -17,8 +17,9 @@ public sealed class ReleaseOrchestratorPluginMonitor : IHostedService, IDisposab
|
||||
private readonly IGateProviderRegistry _gateRegistry;
|
||||
private readonly IConnectorRegistry _connectorRegistry;
|
||||
private readonly ILogger<ReleaseOrchestratorPluginMonitor> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly TimeSpan _monitoringInterval = TimeSpan.FromSeconds(30);
|
||||
private Timer? _timer;
|
||||
private ITimer? _timer;
|
||||
private bool _disposed;
|
||||
|
||||
private readonly Meter _meter;
|
||||
@@ -34,13 +35,15 @@ public sealed class ReleaseOrchestratorPluginMonitor : IHostedService, IDisposab
|
||||
IGateProviderRegistry gateRegistry,
|
||||
IConnectorRegistry connectorRegistry,
|
||||
IMeterFactory meterFactory,
|
||||
ILogger<ReleaseOrchestratorPluginMonitor> logger)
|
||||
ILogger<ReleaseOrchestratorPluginMonitor> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_pluginHost = pluginHost ?? throw new ArgumentNullException(nameof(pluginHost));
|
||||
_stepRegistry = stepRegistry ?? throw new ArgumentNullException(nameof(stepRegistry));
|
||||
_gateRegistry = gateRegistry ?? throw new ArgumentNullException(nameof(gateRegistry));
|
||||
_connectorRegistry = connectorRegistry ?? throw new ArgumentNullException(nameof(connectorRegistry));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
_meter = meterFactory?.Create("StellaOps.ReleaseOrchestrator.Plugin")
|
||||
?? throw new ArgumentNullException(nameof(meterFactory));
|
||||
@@ -72,7 +75,7 @@ public sealed class ReleaseOrchestratorPluginMonitor : IHostedService, IDisposab
|
||||
{
|
||||
_logger.LogInformation("Starting Release Orchestrator plugin monitor");
|
||||
|
||||
_timer = new Timer(
|
||||
_timer = _timeProvider.CreateTimer(
|
||||
MonitorPlugins,
|
||||
null,
|
||||
TimeSpan.FromSeconds(10),
|
||||
@@ -84,7 +87,7 @@ public sealed class ReleaseOrchestratorPluginMonitor : IHostedService, IDisposab
|
||||
public Task StopAsync(CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("Stopping Release Orchestrator plugin monitor");
|
||||
_timer?.Change(Timeout.Infinite, 0);
|
||||
_timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ReleaseComponentExtensions.cs
|
||||
// Sprint: SPRINT_20260118_025_ReleaseOrchestrator_sbom_release_association
|
||||
// Task: TASK-025-001 - Add SbomDigest to ReleaseComponent Model
|
||||
// Description: Extension for ReleaseComponent with canonical SBOM digest support
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Release.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Release component with canonical SBOM digest association.
|
||||
/// </summary>
|
||||
public sealed partial record ReleaseComponentWithSbom
|
||||
{
|
||||
/// <summary>
|
||||
/// SHA-256 regex pattern (64 lowercase hex characters).
|
||||
/// </summary>
|
||||
[GeneratedRegex("^[a-f0-9]{64}$", RegexOptions.Compiled)]
|
||||
private static partial Regex Sha256Pattern();
|
||||
|
||||
/// <summary>
|
||||
/// Component identifier within the release.
|
||||
/// </summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component name (e.g., service name, image name).
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// OCI artifact digest (sha256:...).
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Full artifact reference (registry/repo:tag@digest).
|
||||
/// </summary>
|
||||
public required string Reference { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact type (container, helm, generic).
|
||||
/// </summary>
|
||||
public ArtifactType ArtifactType { get; init; } = ArtifactType.Container;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical SBOM digest (SHA-256 of RFC 8785 canonical JSON).
|
||||
/// Null for releases created before SBOM association was introduced.
|
||||
/// </summary>
|
||||
public string? SbomDigest
|
||||
{
|
||||
get => _sbomDigest;
|
||||
init
|
||||
{
|
||||
if (value != null)
|
||||
{
|
||||
ValidateSha256(value, nameof(SbomDigest));
|
||||
}
|
||||
_sbomDigest = value;
|
||||
}
|
||||
}
|
||||
private readonly string? _sbomDigest;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this component has an associated SBOM digest.
|
||||
/// </summary>
|
||||
public bool HasSbomDigest => !string.IsNullOrEmpty(SbomDigest);
|
||||
|
||||
/// <summary>
|
||||
/// SBOM serial number derived from artifact digest.
|
||||
/// Format: urn:sha256:{artifact-digest-without-prefix}
|
||||
/// </summary>
|
||||
public string DerivedSerialNumber
|
||||
{
|
||||
get
|
||||
{
|
||||
// Extract hash from "sha256:..." format
|
||||
var hash = Digest.Contains(':')
|
||||
? Digest[(Digest.IndexOf(':') + 1)..]
|
||||
: Digest;
|
||||
return $"urn:sha256:{hash}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Component metadata.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validates SHA-256 format.
|
||||
/// </summary>
|
||||
private static void ValidateSha256(string value, string paramName)
|
||||
{
|
||||
if (!Sha256Pattern().IsMatch(value))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Invalid SHA-256 format. Expected 64 lowercase hex characters, got: {value}",
|
||||
paramName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a copy with SBOM digest populated.
|
||||
/// </summary>
|
||||
public ReleaseComponentWithSbom WithSbomDigest(string sbomDigest)
|
||||
{
|
||||
return this with { SbomDigest = sbomDigest };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Artifact type enumeration.
|
||||
/// </summary>
|
||||
public enum ArtifactType
|
||||
{
|
||||
/// <summary>Container image.</summary>
|
||||
Container,
|
||||
|
||||
/// <summary>Helm chart.</summary>
|
||||
Helm,
|
||||
|
||||
/// <summary>Generic OCI artifact.</summary>
|
||||
Generic
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for associating SBOMs with release components during finalization.
|
||||
/// </summary>
|
||||
public interface IReleaseComponentSbomAssociator
|
||||
{
|
||||
/// <summary>
|
||||
/// Associates SBOM digests with release components.
|
||||
/// </summary>
|
||||
/// <param name="components">Components to process.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Components with SBOM digests populated.</returns>
|
||||
Task<IReadOnlyList<ReleaseComponentWithSbom>> AssociateAsync(
|
||||
IEnumerable<ReleaseComponentWithSbom> components,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of SBOM association.
|
||||
/// </summary>
|
||||
public sealed class ReleaseComponentSbomAssociator : IReleaseComponentSbomAssociator
|
||||
{
|
||||
private readonly ISbomLookupService _sbomService;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new SBOM associator.
|
||||
/// </summary>
|
||||
public ReleaseComponentSbomAssociator(ISbomLookupService sbomService)
|
||||
{
|
||||
_sbomService = sbomService ?? throw new ArgumentNullException(nameof(sbomService));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ReleaseComponentWithSbom>> AssociateAsync(
|
||||
IEnumerable<ReleaseComponentWithSbom> components,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var componentList = components.ToList();
|
||||
var digests = componentList.Select(c => c.Digest).Distinct().ToList();
|
||||
|
||||
// Batch lookup to avoid N+1
|
||||
var sbomMap = await _sbomService.GetByDigestsBatchAsync(digests, ct);
|
||||
|
||||
var result = new List<ReleaseComponentWithSbom>(componentList.Count);
|
||||
|
||||
foreach (var component in componentList)
|
||||
{
|
||||
if (sbomMap.TryGetValue(component.Digest, out var sbomInfo))
|
||||
{
|
||||
result.Add(component.WithSbomDigest(sbomInfo.CanonicalDigest));
|
||||
}
|
||||
else
|
||||
{
|
||||
// No SBOM found, keep component as-is
|
||||
result.Add(component);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for SBOM lookup operations.
|
||||
/// </summary>
|
||||
public interface ISbomLookupService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets SBOM info by artifact digest.
|
||||
/// </summary>
|
||||
Task<SbomInfo?> GetByDigestAsync(string artifactDigest, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Batch lookup SBOMs by artifact digests.
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<string, SbomInfo>> GetByDigestsBatchAsync(
|
||||
IEnumerable<string> artifactDigests,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SBOM information for association.
|
||||
/// </summary>
|
||||
public sealed record SbomInfo
|
||||
{
|
||||
/// <summary>SBOM ID.</summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>Artifact digest this SBOM describes.</summary>
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>Canonical SBOM digest (SHA-256 of RFC 8785 JSON).</summary>
|
||||
public required string CanonicalDigest { get; init; }
|
||||
|
||||
/// <summary>SBOM serial number.</summary>
|
||||
public required string SerialNumber { get; init; }
|
||||
|
||||
/// <summary>When the SBOM was created.</summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
@@ -18,7 +18,7 @@ public sealed class VersionWatcher : IHostedService, IDisposable
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<VersionWatcher> _logger;
|
||||
private readonly TimeSpan _pollInterval;
|
||||
private Timer? _timer;
|
||||
private ITimer? _timer;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
@@ -47,7 +47,7 @@ public sealed class VersionWatcher : IHostedService, IDisposable
|
||||
{
|
||||
_logger.LogInformation("Version watcher starting with poll interval {Interval}", _pollInterval);
|
||||
|
||||
_timer = new Timer(
|
||||
_timer = _timeProvider.CreateTimer(
|
||||
PollForNewVersions,
|
||||
null,
|
||||
TimeSpan.FromMinutes(1),
|
||||
@@ -61,7 +61,7 @@ public sealed class VersionWatcher : IHostedService, IDisposable
|
||||
{
|
||||
_logger.LogInformation("Version watcher stopping");
|
||||
|
||||
_timer?.Change(Timeout.Infinite, 0);
|
||||
_timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ public sealed class StepTimeoutHandler : IStepTimeoutHandler, IHostedService, ID
|
||||
private readonly IWorkflowStateManager _stateManager;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<StepTimeoutHandler> _logger;
|
||||
private Timer? _timer;
|
||||
private ITimer? _timer;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
@@ -50,7 +50,7 @@ public sealed class StepTimeoutHandler : IStepTimeoutHandler, IHostedService, ID
|
||||
{
|
||||
_logger.LogInformation("Starting step timeout handler");
|
||||
|
||||
_timer = new Timer(
|
||||
_timer = _timeProvider.CreateTimer(
|
||||
_ => _ = MonitorTimeoutsAsync(ct),
|
||||
null,
|
||||
MonitorInterval,
|
||||
@@ -63,7 +63,7 @@ public sealed class StepTimeoutHandler : IStepTimeoutHandler, IHostedService, ID
|
||||
public Task StopAsync(CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("Stopping step timeout handler");
|
||||
_timer?.Change(Timeout.Infinite, 0);
|
||||
_timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using StellaOps.ReleaseOrchestrator.Agent.Heartbeat;
|
||||
using StellaOps.ReleaseOrchestrator.Agent.Manager;
|
||||
using StellaOps.ReleaseOrchestrator.Agent.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using System.Collections.Immutable;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Agent.Tests.Heartbeat;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="HeartbeatTimeoutMonitor"/> with proper timer testing using FakeTimeProvider.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HeartbeatTimeoutMonitorTests : IDisposable
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly Mock<IAgentManager> _agentManagerMock;
|
||||
private readonly HeartbeatTimeoutMonitor _monitor;
|
||||
private readonly TimeSpan _checkInterval = TimeSpan.FromSeconds(30);
|
||||
private readonly TimeSpan _heartbeatTimeout = TimeSpan.FromMinutes(2);
|
||||
|
||||
public HeartbeatTimeoutMonitorTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 18, 12, 0, 0, TimeSpan.Zero));
|
||||
_agentManagerMock = new Mock<IAgentManager>();
|
||||
_agentManagerMock.Setup(x => x.ListActiveAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<Models.Agent>());
|
||||
|
||||
_monitor = new HeartbeatTimeoutMonitor(
|
||||
_agentManagerMock.Object,
|
||||
_timeProvider,
|
||||
NullLogger<HeartbeatTimeoutMonitor>.Instance,
|
||||
_checkInterval,
|
||||
_heartbeatTimeout);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_monitor.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_CompletesImmediately()
|
||||
{
|
||||
// Act
|
||||
await _monitor.StartAsync(CancellationToken.None);
|
||||
|
||||
// Cleanup
|
||||
await _monitor.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - should not throw
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopAsync_CompletesImmediately()
|
||||
{
|
||||
// Arrange
|
||||
await _monitor.StartAsync(CancellationToken.None);
|
||||
|
||||
// Act
|
||||
await _monitor.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - should not throw
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timer_WhenAgentMissesHeartbeat_MarksAgentStale()
|
||||
{
|
||||
// Arrange
|
||||
var staleAgent = CreateAgent(Guid.NewGuid(), "stale-agent", _timeProvider.GetUtcNow().AddMinutes(-5));
|
||||
_agentManagerMock.Setup(x => x.ListActiveAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[] { staleAgent });
|
||||
|
||||
await _monitor.StartAsync(CancellationToken.None);
|
||||
|
||||
// Act - advance time to trigger timer (initial delay is 1 minute)
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1.5));
|
||||
|
||||
// Allow timer callback to execute
|
||||
await Task.Delay(100);
|
||||
|
||||
// Assert
|
||||
_agentManagerMock.Verify(
|
||||
x => x.MarkStaleAsync(staleAgent.Id, It.IsAny<CancellationToken>()),
|
||||
Times.AtLeastOnce);
|
||||
|
||||
await _monitor.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timer_WhenAgentHasRecentHeartbeat_DoesNotMarkStale()
|
||||
{
|
||||
// Arrange
|
||||
var healthyAgent = CreateAgent(Guid.NewGuid(), "healthy-agent", _timeProvider.GetUtcNow().AddSeconds(-30));
|
||||
_agentManagerMock.Setup(x => x.ListActiveAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[] { healthyAgent });
|
||||
|
||||
await _monitor.StartAsync(CancellationToken.None);
|
||||
|
||||
// Act - advance time to trigger timer
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1.5));
|
||||
|
||||
// Allow timer callback to execute
|
||||
await Task.Delay(100);
|
||||
|
||||
// Assert - should NOT mark as stale
|
||||
_agentManagerMock.Verify(
|
||||
x => x.MarkStaleAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
|
||||
await _monitor.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timer_WithMultipleAgents_OnlyMarksStaleOnes()
|
||||
{
|
||||
// Arrange
|
||||
var staleAgentId = Guid.NewGuid();
|
||||
var healthyAgentId = Guid.NewGuid();
|
||||
|
||||
var agents = new[]
|
||||
{
|
||||
CreateAgent(staleAgentId, "stale-agent", _timeProvider.GetUtcNow().AddMinutes(-5)),
|
||||
CreateAgent(healthyAgentId, "healthy-agent", _timeProvider.GetUtcNow().AddSeconds(-30))
|
||||
};
|
||||
_agentManagerMock.Setup(x => x.ListActiveAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(agents);
|
||||
|
||||
await _monitor.StartAsync(CancellationToken.None);
|
||||
|
||||
// Act - advance time to trigger timer
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1.5));
|
||||
|
||||
// Allow timer callback to execute
|
||||
await Task.Delay(100);
|
||||
|
||||
// Assert
|
||||
_agentManagerMock.Verify(
|
||||
x => x.MarkStaleAsync(staleAgentId, It.IsAny<CancellationToken>()),
|
||||
Times.AtLeastOnce);
|
||||
_agentManagerMock.Verify(
|
||||
x => x.MarkStaleAsync(healthyAgentId, It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
|
||||
await _monitor.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timer_WhenAgentHasNoHeartbeat_Skips()
|
||||
{
|
||||
// Arrange
|
||||
var agentWithNoHeartbeat = CreateAgent(Guid.NewGuid(), "new-agent", null);
|
||||
_agentManagerMock.Setup(x => x.ListActiveAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[] { agentWithNoHeartbeat });
|
||||
|
||||
await _monitor.StartAsync(CancellationToken.None);
|
||||
|
||||
// Act - advance time to trigger timer
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1.5));
|
||||
|
||||
// Allow timer callback to execute
|
||||
await Task.Delay(100);
|
||||
|
||||
// Assert - should NOT mark as stale (no heartbeat to check)
|
||||
_agentManagerMock.Verify(
|
||||
x => x.MarkStaleAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
|
||||
await _monitor.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private static Models.Agent CreateAgent(Guid id, string name, DateTimeOffset? lastHeartbeat) => new()
|
||||
{
|
||||
Id = id,
|
||||
TenantId = Guid.NewGuid(),
|
||||
Name = name,
|
||||
DisplayName = name,
|
||||
Version = "1.0.0",
|
||||
Status = AgentStatus.Active,
|
||||
Capabilities = ImmutableArray<AgentCapability>.Empty,
|
||||
LastHeartbeatAt = lastHeartbeat
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Health;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Services;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Target;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.Tests.Health;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="HealthCheckScheduler"/> with proper timer testing using FakeTimeProvider.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HealthCheckSchedulerTests : IDisposable
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly Mock<ITargetRegistry> _targetRegistryMock;
|
||||
private readonly Mock<IEnvironmentService> _environmentServiceMock;
|
||||
private readonly Mock<ITargetHealthChecker> _healthCheckerMock;
|
||||
private readonly HealthCheckScheduler _scheduler;
|
||||
private readonly TimeSpan _checkInterval = TimeSpan.FromMinutes(1);
|
||||
private readonly TimeSpan _initialDelay = TimeSpan.FromSeconds(30);
|
||||
|
||||
public HealthCheckSchedulerTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 18, 12, 0, 0, TimeSpan.Zero));
|
||||
_targetRegistryMock = new Mock<ITargetRegistry>();
|
||||
_environmentServiceMock = new Mock<IEnvironmentService>();
|
||||
_healthCheckerMock = new Mock<ITargetHealthChecker>();
|
||||
|
||||
_environmentServiceMock.Setup(x => x.ListAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<EnvironmentInfo>());
|
||||
|
||||
_scheduler = new HealthCheckScheduler(
|
||||
_targetRegistryMock.Object,
|
||||
_environmentServiceMock.Object,
|
||||
_healthCheckerMock.Object,
|
||||
NullLogger<HealthCheckScheduler>.Instance,
|
||||
_timeProvider,
|
||||
_checkInterval,
|
||||
_initialDelay);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_scheduler.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_CompletesImmediately()
|
||||
{
|
||||
// Act
|
||||
await _scheduler.StartAsync(CancellationToken.None);
|
||||
|
||||
// Cleanup
|
||||
await _scheduler.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - should not throw
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopAsync_CompletesImmediately()
|
||||
{
|
||||
// Arrange
|
||||
await _scheduler.StartAsync(CancellationToken.None);
|
||||
|
||||
// Act
|
||||
await _scheduler.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - should not throw
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timer_AfterInitialDelay_PerformsHealthCheck()
|
||||
{
|
||||
// Arrange
|
||||
var envId = Guid.NewGuid();
|
||||
var targetId = Guid.NewGuid();
|
||||
|
||||
var environments = new List<EnvironmentInfo>
|
||||
{
|
||||
new() { Id = envId, Name = "test-env" }
|
||||
};
|
||||
var targets = new List<DeploymentTarget>
|
||||
{
|
||||
new() { Id = targetId, Name = "test-target", EnvironmentId = envId }
|
||||
};
|
||||
var healthResult = new HealthCheckResult
|
||||
{
|
||||
Status = HealthStatus.Healthy,
|
||||
Message = "OK"
|
||||
};
|
||||
|
||||
_environmentServiceMock.Setup(x => x.ListAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(environments);
|
||||
_targetRegistryMock.Setup(x => x.ListByEnvironmentAsync(envId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(targets);
|
||||
_healthCheckerMock.Setup(x => x.CheckAsync(It.IsAny<DeploymentTarget>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(healthResult);
|
||||
|
||||
await _scheduler.StartAsync(CancellationToken.None);
|
||||
|
||||
// Act - advance time past initial delay
|
||||
_timeProvider.Advance(_initialDelay + TimeSpan.FromSeconds(5));
|
||||
|
||||
// Allow timer callback to execute
|
||||
await Task.Delay(100);
|
||||
|
||||
// Assert
|
||||
_healthCheckerMock.Verify(
|
||||
x => x.CheckAsync(It.Is<DeploymentTarget>(t => t.Id == targetId), It.IsAny<CancellationToken>()),
|
||||
Times.AtLeastOnce);
|
||||
|
||||
await _scheduler.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timer_BeforeInitialDelay_DoesNotPerformHealthCheck()
|
||||
{
|
||||
// Arrange
|
||||
var environments = new List<EnvironmentInfo>
|
||||
{
|
||||
new() { Id = Guid.NewGuid(), Name = "test-env" }
|
||||
};
|
||||
_environmentServiceMock.Setup(x => x.ListAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(environments);
|
||||
|
||||
await _scheduler.StartAsync(CancellationToken.None);
|
||||
|
||||
// Act - advance time but NOT past initial delay
|
||||
_timeProvider.Advance(_initialDelay - TimeSpan.FromSeconds(10));
|
||||
|
||||
// Allow potential timer callback
|
||||
await Task.Delay(50);
|
||||
|
||||
// Assert
|
||||
_healthCheckerMock.Verify(
|
||||
x => x.CheckAsync(It.IsAny<DeploymentTarget>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
|
||||
await _scheduler.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timer_UpdatesTargetHealth()
|
||||
{
|
||||
// Arrange
|
||||
var envId = Guid.NewGuid();
|
||||
var targetId = Guid.NewGuid();
|
||||
|
||||
var environments = new List<EnvironmentInfo>
|
||||
{
|
||||
new() { Id = envId, Name = "test-env" }
|
||||
};
|
||||
var targets = new List<DeploymentTarget>
|
||||
{
|
||||
new() { Id = targetId, Name = "test-target", EnvironmentId = envId }
|
||||
};
|
||||
var healthResult = new HealthCheckResult
|
||||
{
|
||||
Status = HealthStatus.Unhealthy,
|
||||
Message = "Connection failed"
|
||||
};
|
||||
|
||||
_environmentServiceMock.Setup(x => x.ListAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(environments);
|
||||
_targetRegistryMock.Setup(x => x.ListByEnvironmentAsync(envId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(targets);
|
||||
_healthCheckerMock.Setup(x => x.CheckAsync(It.IsAny<DeploymentTarget>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(healthResult);
|
||||
|
||||
await _scheduler.StartAsync(CancellationToken.None);
|
||||
|
||||
// Act - advance time past initial delay
|
||||
_timeProvider.Advance(_initialDelay + TimeSpan.FromSeconds(5));
|
||||
|
||||
// Allow timer callback to execute
|
||||
await Task.Delay(100);
|
||||
|
||||
// Assert - should update health status
|
||||
_targetRegistryMock.Verify(
|
||||
x => x.UpdateHealthAsync(
|
||||
targetId,
|
||||
HealthStatus.Unhealthy,
|
||||
"Connection failed",
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.AtLeastOnce);
|
||||
|
||||
await _scheduler.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timer_MultipleIntervals_PerformsMultipleChecks()
|
||||
{
|
||||
// Arrange
|
||||
var envId = Guid.NewGuid();
|
||||
var environments = new List<EnvironmentInfo>
|
||||
{
|
||||
new() { Id = envId, Name = "test-env" }
|
||||
};
|
||||
var targets = new List<DeploymentTarget>
|
||||
{
|
||||
new() { Id = Guid.NewGuid(), Name = "test-target", EnvironmentId = envId }
|
||||
};
|
||||
var healthResult = new HealthCheckResult { Status = HealthStatus.Healthy, Message = "OK" };
|
||||
|
||||
_environmentServiceMock.Setup(x => x.ListAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(environments);
|
||||
_targetRegistryMock.Setup(x => x.ListByEnvironmentAsync(envId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(targets);
|
||||
_healthCheckerMock.Setup(x => x.CheckAsync(It.IsAny<DeploymentTarget>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(healthResult);
|
||||
|
||||
await _scheduler.StartAsync(CancellationToken.None);
|
||||
|
||||
// Act - advance time through multiple check intervals
|
||||
_timeProvider.Advance(_initialDelay + TimeSpan.FromSeconds(5));
|
||||
await Task.Delay(50);
|
||||
_timeProvider.Advance(_checkInterval);
|
||||
await Task.Delay(50);
|
||||
_timeProvider.Advance(_checkInterval);
|
||||
await Task.Delay(50);
|
||||
|
||||
// Assert - should have multiple checks
|
||||
_healthCheckerMock.Verify(
|
||||
x => x.CheckAsync(It.IsAny<DeploymentTarget>(), It.IsAny<CancellationToken>()),
|
||||
Times.AtLeast(2));
|
||||
|
||||
await _scheduler.StopAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Inventory;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Services;
|
||||
using StellaOps.ReleaseOrchestrator.Environment.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Environment.Tests.Inventory;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="SyncScheduler"/> with proper timer testing using FakeTimeProvider.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SyncSchedulerTests : IDisposable
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly Mock<IInventorySyncService> _syncServiceMock;
|
||||
private readonly Mock<IEnvironmentService> _environmentServiceMock;
|
||||
private readonly SyncScheduler _scheduler;
|
||||
private readonly TimeSpan _syncInterval = TimeSpan.FromMinutes(5);
|
||||
private readonly TimeSpan _initialDelay = TimeSpan.FromMinutes(2);
|
||||
|
||||
public SyncSchedulerTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 18, 12, 0, 0, TimeSpan.Zero));
|
||||
_syncServiceMock = new Mock<IInventorySyncService>();
|
||||
_environmentServiceMock = new Mock<IEnvironmentService>();
|
||||
|
||||
_environmentServiceMock.Setup(x => x.ListAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<EnvironmentInfo>());
|
||||
|
||||
_scheduler = new SyncScheduler(
|
||||
_syncServiceMock.Object,
|
||||
_environmentServiceMock.Object,
|
||||
_timeProvider,
|
||||
NullLogger<SyncScheduler>.Instance,
|
||||
_syncInterval,
|
||||
_initialDelay);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_scheduler.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_CompletesImmediately()
|
||||
{
|
||||
// Act
|
||||
await _scheduler.StartAsync(CancellationToken.None);
|
||||
|
||||
// Cleanup
|
||||
await _scheduler.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - should not throw
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopAsync_CompletesImmediately()
|
||||
{
|
||||
// Arrange
|
||||
await _scheduler.StartAsync(CancellationToken.None);
|
||||
|
||||
// Act
|
||||
await _scheduler.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - should not throw
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timer_AfterInitialDelay_PerformsSync()
|
||||
{
|
||||
// Arrange
|
||||
var envId = Guid.NewGuid();
|
||||
var environments = new List<EnvironmentInfo>
|
||||
{
|
||||
new() { Id = envId, Name = "test-env" }
|
||||
};
|
||||
var syncResults = new List<InventorySnapshot>
|
||||
{
|
||||
new() { IsSuccessful = true }
|
||||
};
|
||||
|
||||
_environmentServiceMock.Setup(x => x.ListAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(environments);
|
||||
_syncServiceMock.Setup(x => x.SyncEnvironmentAsync(envId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(syncResults);
|
||||
|
||||
await _scheduler.StartAsync(CancellationToken.None);
|
||||
|
||||
// Act - advance time past initial delay
|
||||
_timeProvider.Advance(_initialDelay + TimeSpan.FromSeconds(5));
|
||||
|
||||
// Allow timer callback to execute
|
||||
await Task.Delay(100);
|
||||
|
||||
// Assert
|
||||
_syncServiceMock.Verify(
|
||||
x => x.SyncEnvironmentAsync(envId, It.IsAny<CancellationToken>()),
|
||||
Times.AtLeastOnce);
|
||||
|
||||
await _scheduler.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timer_BeforeInitialDelay_DoesNotPerformSync()
|
||||
{
|
||||
// Arrange
|
||||
var environments = new List<EnvironmentInfo>
|
||||
{
|
||||
new() { Id = Guid.NewGuid(), Name = "test-env" }
|
||||
};
|
||||
_environmentServiceMock.Setup(x => x.ListAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(environments);
|
||||
|
||||
await _scheduler.StartAsync(CancellationToken.None);
|
||||
|
||||
// Act - advance time but NOT past initial delay
|
||||
_timeProvider.Advance(_initialDelay - TimeSpan.FromSeconds(30));
|
||||
|
||||
// Allow potential timer callback
|
||||
await Task.Delay(50);
|
||||
|
||||
// Assert
|
||||
_syncServiceMock.Verify(
|
||||
x => x.SyncEnvironmentAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
|
||||
await _scheduler.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timer_SyncsMultipleEnvironments()
|
||||
{
|
||||
// Arrange
|
||||
var env1Id = Guid.NewGuid();
|
||||
var env2Id = Guid.NewGuid();
|
||||
var environments = new List<EnvironmentInfo>
|
||||
{
|
||||
new() { Id = env1Id, Name = "env1" },
|
||||
new() { Id = env2Id, Name = "env2" }
|
||||
};
|
||||
var syncResults = new List<InventorySnapshot> { new() { IsSuccessful = true } };
|
||||
|
||||
_environmentServiceMock.Setup(x => x.ListAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(environments);
|
||||
_syncServiceMock.Setup(x => x.SyncEnvironmentAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(syncResults);
|
||||
|
||||
await _scheduler.StartAsync(CancellationToken.None);
|
||||
|
||||
// Act - advance time past initial delay
|
||||
_timeProvider.Advance(_initialDelay + TimeSpan.FromSeconds(5));
|
||||
|
||||
// Allow timer callback to execute
|
||||
await Task.Delay(100);
|
||||
|
||||
// Assert - both environments should be synced
|
||||
_syncServiceMock.Verify(
|
||||
x => x.SyncEnvironmentAsync(env1Id, It.IsAny<CancellationToken>()),
|
||||
Times.AtLeastOnce);
|
||||
_syncServiceMock.Verify(
|
||||
x => x.SyncEnvironmentAsync(env2Id, It.IsAny<CancellationToken>()),
|
||||
Times.AtLeastOnce);
|
||||
|
||||
await _scheduler.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timer_ContinuesOnSyncFailure()
|
||||
{
|
||||
// Arrange
|
||||
var env1Id = Guid.NewGuid();
|
||||
var env2Id = Guid.NewGuid();
|
||||
var environments = new List<EnvironmentInfo>
|
||||
{
|
||||
new() { Id = env1Id, Name = "failing-env" },
|
||||
new() { Id = env2Id, Name = "healthy-env" }
|
||||
};
|
||||
var syncResults = new List<InventorySnapshot> { new() { IsSuccessful = true } };
|
||||
|
||||
_environmentServiceMock.Setup(x => x.ListAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(environments);
|
||||
_syncServiceMock.Setup(x => x.SyncEnvironmentAsync(env1Id, It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Sync failed"));
|
||||
_syncServiceMock.Setup(x => x.SyncEnvironmentAsync(env2Id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(syncResults);
|
||||
|
||||
await _scheduler.StartAsync(CancellationToken.None);
|
||||
|
||||
// Act - advance time past initial delay
|
||||
_timeProvider.Advance(_initialDelay + TimeSpan.FromSeconds(5));
|
||||
|
||||
// Allow timer callback to execute
|
||||
await Task.Delay(100);
|
||||
|
||||
// Assert - second environment should still be synced despite first failing
|
||||
_syncServiceMock.Verify(
|
||||
x => x.SyncEnvironmentAsync(env2Id, It.IsAny<CancellationToken>()),
|
||||
Times.AtLeastOnce);
|
||||
|
||||
await _scheduler.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timer_MultipleIntervals_PerformsMultipleSyncs()
|
||||
{
|
||||
// Arrange
|
||||
var envId = Guid.NewGuid();
|
||||
var environments = new List<EnvironmentInfo>
|
||||
{
|
||||
new() { Id = envId, Name = "test-env" }
|
||||
};
|
||||
var syncResults = new List<InventorySnapshot> { new() { IsSuccessful = true } };
|
||||
|
||||
_environmentServiceMock.Setup(x => x.ListAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(environments);
|
||||
_syncServiceMock.Setup(x => x.SyncEnvironmentAsync(envId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(syncResults);
|
||||
|
||||
await _scheduler.StartAsync(CancellationToken.None);
|
||||
|
||||
// Act - advance time through multiple sync intervals
|
||||
_timeProvider.Advance(_initialDelay + TimeSpan.FromSeconds(5));
|
||||
await Task.Delay(50);
|
||||
_timeProvider.Advance(_syncInterval);
|
||||
await Task.Delay(50);
|
||||
_timeProvider.Advance(_syncInterval);
|
||||
await Task.Delay(50);
|
||||
|
||||
// Assert - should have multiple syncs
|
||||
_syncServiceMock.Verify(
|
||||
x => x.SyncEnvironmentAsync(envId, It.IsAny<CancellationToken>()),
|
||||
Times.AtLeast(2));
|
||||
|
||||
await _scheduler.StopAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DeterministicSbomGateTests.cs
|
||||
// Sprint: SPRINT_20260118_025_ReleaseOrchestrator_sbom_release_association
|
||||
// Task: TASK-025-006 - CI Gate Integration Test
|
||||
// Description: End-to-end integration tests for deterministic SBOM flow
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.StandardPredicates.Writers;
|
||||
using StellaOps.ReleaseOrchestrator.Release.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Integration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the deterministic SBOM gate workflow.
|
||||
/// Sprint: SPRINT_20260118_025_ReleaseOrchestrator_sbom_release_association (TASK-025-006)
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// These tests verify the full deterministic SBOM flow:
|
||||
/// 1. Generate SBOM for test artifact
|
||||
/// 2. Canonicalize and compute golden hash
|
||||
/// 3. Sign with DSSE (simulated)
|
||||
/// 4. Create release with component referencing the artifact
|
||||
/// 5. Finalize release → verify SbomDigest captured
|
||||
/// 6. Rebuild SBOM from same artifact
|
||||
/// 7. Verify sha256(rebuilt) == sha256(original)
|
||||
/// 8. Verify DSSE signature (simulated)
|
||||
/// </remarks>
|
||||
public sealed class DeterministicSbomGateTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Frozen artifact digest for deterministic testing (SHA-256 of test artifact).
|
||||
/// </summary>
|
||||
private const string FrozenArtifactDigest = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
||||
|
||||
/// <summary>
|
||||
/// Expected serial number for the frozen artifact.
|
||||
/// </summary>
|
||||
private const string ExpectedSerialNumber = "urn:sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
||||
|
||||
/// <summary>
|
||||
/// Fixed timestamp for deterministic testing.
|
||||
/// </summary>
|
||||
private static readonly DateTimeOffset FixedTimestamp = new(2026, 1, 18, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private readonly CycloneDxWriter _writer = new();
|
||||
|
||||
#region SBOM Generation Determinism Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that generating SBOM for the same artifact produces identical output.
|
||||
/// This is the core determinism requirement for CI gates.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GenerateSbom_SameArtifact_ProducesIdenticalHash()
|
||||
{
|
||||
// Arrange - Create a frozen document representing our test artifact
|
||||
var document1 = CreateFrozenSbomDocument();
|
||||
var document2 = CreateFrozenSbomDocument();
|
||||
|
||||
// Act - Generate SBOM bytes twice
|
||||
var bytes1 = _writer.Write(document1);
|
||||
var bytes2 = _writer.Write(document2);
|
||||
|
||||
// Assert - Byte-for-byte identical
|
||||
bytes1.Should().BeEquivalentTo(bytes2, "Same artifact must produce identical SBOM bytes");
|
||||
|
||||
// Verify hashes match
|
||||
var hash1 = ComputeSha256Hex(bytes1);
|
||||
var hash2 = ComputeSha256Hex(bytes2);
|
||||
hash1.Should().Be(hash2, "SBOM hashes must match for determinism");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies 100 consecutive SBOM generations produce identical output.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GenerateSbom_100Iterations_AllIdentical()
|
||||
{
|
||||
// Arrange
|
||||
var hashes = new HashSet<string>();
|
||||
var baseDocument = CreateFrozenSbomDocument();
|
||||
|
||||
// Act - Generate 100 times
|
||||
for (var i = 0; i < 100; i++)
|
||||
{
|
||||
var document = CreateFrozenSbomDocument();
|
||||
var bytes = _writer.Write(document);
|
||||
var hash = ComputeSha256Hex(bytes);
|
||||
hashes.Add(hash);
|
||||
}
|
||||
|
||||
// Assert - All iterations produced same hash
|
||||
hashes.Should().HaveCount(1, "All 100 iterations must produce identical SBOM hash");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that serialNumber uses artifact digest in urn:sha256: format.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GenerateSbom_HasCorrectSerialNumber()
|
||||
{
|
||||
// Arrange
|
||||
var document = CreateFrozenSbomDocument();
|
||||
|
||||
// Act
|
||||
var bytes = _writer.Write(document);
|
||||
var json = Encoding.UTF8.GetString(bytes);
|
||||
var parsed = JsonDocument.Parse(json);
|
||||
var serialNumber = parsed.RootElement.GetProperty("serialNumber").GetString();
|
||||
|
||||
// Assert
|
||||
serialNumber.Should().Be(ExpectedSerialNumber,
|
||||
"serialNumber must use urn:sha256:<artifact-digest> format for determinism");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Release Component Association Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies ReleaseComponentWithSbom correctly stores and validates SBOM digest.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ReleaseComponent_WithSbomDigest_StoresCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var document = CreateFrozenSbomDocument();
|
||||
var sbomBytes = _writer.Write(document);
|
||||
var sbomDigest = ComputeSha256Hex(sbomBytes);
|
||||
|
||||
// Act
|
||||
var component = new ReleaseComponentWithSbom
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "test-app",
|
||||
Digest = $"sha256:{FrozenArtifactDigest}",
|
||||
Reference = $"registry.example.com/test-app:v1.0.0@sha256:{FrozenArtifactDigest}",
|
||||
SbomDigest = sbomDigest
|
||||
};
|
||||
|
||||
// Assert
|
||||
component.HasSbomDigest.Should().BeTrue();
|
||||
component.SbomDigest.Should().Be(sbomDigest);
|
||||
component.SbomDigest.Should().MatchRegex("^[a-f0-9]{64}$",
|
||||
"SBOM digest must be 64 lowercase hex characters");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies DerivedSerialNumber matches expected format.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ReleaseComponent_DerivedSerialNumber_MatchesExpected()
|
||||
{
|
||||
// Arrange
|
||||
var component = new ReleaseComponentWithSbom
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "test-app",
|
||||
Digest = $"sha256:{FrozenArtifactDigest}",
|
||||
Reference = "registry.example.com/test-app:v1.0.0"
|
||||
};
|
||||
|
||||
// Act
|
||||
var derivedSerialNumber = component.DerivedSerialNumber;
|
||||
|
||||
// Assert
|
||||
derivedSerialNumber.Should().Be(ExpectedSerialNumber,
|
||||
"DerivedSerialNumber must match urn:sha256:<artifact-digest> format");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies ReleaseComponent rejects invalid SHA-256 format.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ReleaseComponent_InvalidSbomDigest_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange & Act
|
||||
var act = () => new ReleaseComponentWithSbom
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "test-app",
|
||||
Digest = $"sha256:{FrozenArtifactDigest}",
|
||||
Reference = "registry.example.com/test-app:v1.0.0",
|
||||
SbomDigest = "invalid-not-64-hex" // Invalid format
|
||||
};
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithMessage("*Invalid SHA-256 format*");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies ReleaseComponent allows null SbomDigest for backwards compatibility.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ReleaseComponent_NullSbomDigest_IsAllowed()
|
||||
{
|
||||
// Arrange & Act
|
||||
var component = new ReleaseComponentWithSbom
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "test-app",
|
||||
Digest = $"sha256:{FrozenArtifactDigest}",
|
||||
Reference = "registry.example.com/test-app:v1.0.0",
|
||||
SbomDigest = null
|
||||
};
|
||||
|
||||
// Assert
|
||||
component.HasSbomDigest.Should().BeFalse();
|
||||
component.SbomDigest.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region End-to-End CI Gate Workflow Tests
|
||||
|
||||
/// <summary>
|
||||
/// Simulates the complete CI gate workflow:
|
||||
/// Generate SBOM → Canonicalize → Sign → Create Release → Finalize → Verify
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CIGateWorkflow_Complete_VerifiesDeterminism()
|
||||
{
|
||||
// Step 1: Generate SBOM for test artifact
|
||||
var document = CreateFrozenSbomDocument();
|
||||
var sbomBytes = _writer.Write(document);
|
||||
var goldenHash = ComputeSha256Hex(sbomBytes);
|
||||
|
||||
// Step 2: Verify canonical form (simulated - in real scenario, CLI does this)
|
||||
var isCanonical = VerifyCanonicalForm(sbomBytes);
|
||||
isCanonical.Should().BeTrue("SBOM must be in canonical form");
|
||||
|
||||
// Step 3: Sign with DSSE (simulated)
|
||||
var signature = await SimulateDsseSignAsync(sbomBytes);
|
||||
signature.Should().NotBeNullOrEmpty("DSSE signature must be generated");
|
||||
|
||||
// Step 4: Create release component with SBOM association
|
||||
var component = new ReleaseComponentWithSbom
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "test-app",
|
||||
Digest = $"sha256:{FrozenArtifactDigest}",
|
||||
Reference = $"registry.example.com/test-app:v1.0.0@sha256:{FrozenArtifactDigest}",
|
||||
SbomDigest = goldenHash
|
||||
};
|
||||
|
||||
// Step 5: Verify SbomDigest was captured
|
||||
component.SbomDigest.Should().Be(goldenHash);
|
||||
|
||||
// Step 6: Rebuild SBOM from same artifact (simulating re-generation)
|
||||
var rebuiltDocument = CreateFrozenSbomDocument();
|
||||
var rebuiltBytes = _writer.Write(rebuiltDocument);
|
||||
var rebuiltHash = ComputeSha256Hex(rebuiltBytes);
|
||||
|
||||
// Step 7: Verify rebuilt hash matches original
|
||||
rebuiltHash.Should().Be(goldenHash,
|
||||
"Rebuilt SBOM must produce identical hash for deterministic verification");
|
||||
|
||||
// Step 8: Verify DSSE signature (simulated)
|
||||
var signatureValid = await SimulateVerifyDsseSignatureAsync(rebuiltBytes, signature);
|
||||
signatureValid.Should().BeTrue("DSSE signature must validate against rebuilt SBOM");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that different artifacts produce different SBOM hashes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CIGateWorkflow_DifferentArtifacts_ProduceDifferentHashes()
|
||||
{
|
||||
// Arrange
|
||||
var doc1 = CreateFrozenSbomDocument();
|
||||
var doc2 = CreateSbomDocumentWithDifferentArtifact();
|
||||
|
||||
// Act
|
||||
var bytes1 = _writer.Write(doc1);
|
||||
var bytes2 = _writer.Write(doc2);
|
||||
var hash1 = ComputeSha256Hex(bytes1);
|
||||
var hash2 = ComputeSha256Hex(bytes2);
|
||||
|
||||
// Assert
|
||||
hash1.Should().NotBe(hash2,
|
||||
"Different artifacts must produce different SBOM hashes");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies SBOM associator workflow with mock service.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SbomAssociator_BatchLookup_PopulatesDigests()
|
||||
{
|
||||
// Arrange
|
||||
var sbomService = new FakeSbomLookupService();
|
||||
var associator = new ReleaseComponentSbomAssociator(sbomService);
|
||||
|
||||
var components = new[]
|
||||
{
|
||||
new ReleaseComponentWithSbom
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "app-1",
|
||||
Digest = $"sha256:{FrozenArtifactDigest}",
|
||||
Reference = "registry/app-1:v1"
|
||||
},
|
||||
new ReleaseComponentWithSbom
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "app-2",
|
||||
Digest = "sha256:a3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
Reference = "registry/app-2:v1"
|
||||
}
|
||||
};
|
||||
|
||||
// Pre-register SBOMs in fake service
|
||||
sbomService.RegisterSbom($"sha256:{FrozenArtifactDigest}", CreateFakeSbomInfo(FrozenArtifactDigest));
|
||||
sbomService.RegisterSbom(
|
||||
"sha256:a3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
CreateFakeSbomInfo("a3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"));
|
||||
|
||||
// Act
|
||||
var result = await associator.AssociateAsync(components);
|
||||
|
||||
// Assert
|
||||
result.Should().HaveCount(2);
|
||||
result.All(c => c.HasSbomDigest).Should().BeTrue("All components should have SBOM digests");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Serialization Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies ReleaseComponentWithSbom serializes correctly with SbomDigest.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ReleaseComponent_Serialization_IncludesSbomDigest()
|
||||
{
|
||||
// Arrange
|
||||
var component = new ReleaseComponentWithSbom
|
||||
{
|
||||
Id = Guid.Parse("12345678-1234-1234-1234-123456789abc"),
|
||||
Name = "test-app",
|
||||
Digest = $"sha256:{FrozenArtifactDigest}",
|
||||
Reference = "registry.example.com/test-app:v1.0.0",
|
||||
SbomDigest = FrozenArtifactDigest
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(component, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
// Assert
|
||||
json.Should().Contain("\"sbomDigest\"");
|
||||
json.Should().Contain(FrozenArtifactDigest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies backward compatibility - deserialization works without SbomDigest.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ReleaseComponent_Deserialization_WithoutSbomDigest_Succeeds()
|
||||
{
|
||||
// Arrange - JSON without SbomDigest (legacy format)
|
||||
var json = """
|
||||
{
|
||||
"id": "12345678-1234-1234-1234-123456789abc",
|
||||
"name": "test-app",
|
||||
"digest": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"reference": "registry.example.com/test-app:v1.0.0"
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var component = JsonSerializer.Deserialize<ReleaseComponentWithSbom>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
// Assert
|
||||
component.Should().NotBeNull();
|
||||
component!.SbomDigest.Should().BeNull();
|
||||
component.HasSbomDigest.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static SbomDocument CreateFrozenSbomDocument()
|
||||
{
|
||||
return new SbomDocument
|
||||
{
|
||||
Name = "test-app",
|
||||
Version = "1.0.0",
|
||||
CreatedAt = FixedTimestamp,
|
||||
ArtifactDigest = FrozenArtifactDigest,
|
||||
Components =
|
||||
[
|
||||
new SbomComponent
|
||||
{
|
||||
BomRef = "lodash",
|
||||
Name = "lodash",
|
||||
Version = "4.17.21",
|
||||
Type = "library",
|
||||
Purl = "pkg:npm/lodash@4.17.21"
|
||||
},
|
||||
new SbomComponent
|
||||
{
|
||||
BomRef = "express",
|
||||
Name = "express",
|
||||
Version = "4.18.2",
|
||||
Type = "library",
|
||||
Purl = "pkg:npm/express@4.18.2"
|
||||
}
|
||||
],
|
||||
Dependencies =
|
||||
[
|
||||
new SbomDependency
|
||||
{
|
||||
Ref = "test-app",
|
||||
DependsOn = ["express", "lodash"]
|
||||
},
|
||||
new SbomDependency
|
||||
{
|
||||
Ref = "express",
|
||||
DependsOn = ["lodash"]
|
||||
}
|
||||
],
|
||||
Tool = new SbomTool
|
||||
{
|
||||
Vendor = "Stella Ops",
|
||||
Name = "stella-scanner",
|
||||
Version = "1.0.0"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static SbomDocument CreateSbomDocumentWithDifferentArtifact()
|
||||
{
|
||||
return new SbomDocument
|
||||
{
|
||||
Name = "different-app",
|
||||
Version = "2.0.0",
|
||||
CreatedAt = FixedTimestamp,
|
||||
ArtifactDigest = "a3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
Components =
|
||||
[
|
||||
new SbomComponent
|
||||
{
|
||||
BomRef = "axios",
|
||||
Name = "axios",
|
||||
Version = "1.6.0",
|
||||
Type = "library"
|
||||
}
|
||||
],
|
||||
Tool = new SbomTool
|
||||
{
|
||||
Name = "stella-scanner",
|
||||
Version = "1.0.0"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(byte[] data)
|
||||
{
|
||||
var hash = SHA256.HashData(data);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static bool VerifyCanonicalForm(byte[] bytes)
|
||||
{
|
||||
// Verify JSON is minified (no unnecessary whitespace)
|
||||
// Verify keys are sorted (JCS requirement)
|
||||
try
|
||||
{
|
||||
var json = Encoding.UTF8.GetString(bytes);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
// Basic check: no leading/trailing whitespace in serialized form
|
||||
var trimmed = json.Trim();
|
||||
if (json != trimmed) return false;
|
||||
|
||||
// Check starts with { and ends with }
|
||||
if (!json.StartsWith('{') || !json.EndsWith('}')) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static Task<string> SimulateDsseSignAsync(byte[] payload)
|
||||
{
|
||||
// Simulated DSSE signing - in production, this would use real crypto
|
||||
var hash = SHA256.HashData(payload);
|
||||
var signature = Convert.ToBase64String(hash);
|
||||
return Task.FromResult(signature);
|
||||
}
|
||||
|
||||
private static Task<bool> SimulateVerifyDsseSignatureAsync(byte[] payload, string signature)
|
||||
{
|
||||
// Simulated DSSE verification - in production, this would use real crypto
|
||||
var hash = SHA256.HashData(payload);
|
||||
var expectedSignature = Convert.ToBase64String(hash);
|
||||
return Task.FromResult(signature == expectedSignature);
|
||||
}
|
||||
|
||||
private static SbomInfo CreateFakeSbomInfo(string artifactDigest)
|
||||
{
|
||||
var canonicalDigest = ComputeSha256Hex(Encoding.UTF8.GetBytes($"sbom-for-{artifactDigest}"));
|
||||
return new SbomInfo
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ArtifactDigest = $"sha256:{artifactDigest}",
|
||||
CanonicalDigest = canonicalDigest,
|
||||
SerialNumber = $"urn:sha256:{artifactDigest}",
|
||||
CreatedAt = FixedTimestamp
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Doubles
|
||||
|
||||
private sealed class FakeSbomLookupService : ISbomLookupService
|
||||
{
|
||||
private readonly Dictionary<string, SbomInfo> _sboms = new();
|
||||
|
||||
public void RegisterSbom(string artifactDigest, SbomInfo info)
|
||||
{
|
||||
_sboms[artifactDigest] = info;
|
||||
}
|
||||
|
||||
public Task<SbomInfo?> GetByDigestAsync(string artifactDigest, CancellationToken ct = default)
|
||||
{
|
||||
_sboms.TryGetValue(artifactDigest, out var info);
|
||||
return Task.FromResult(info);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyDictionary<string, SbomInfo>> GetByDigestsBatchAsync(
|
||||
IEnumerable<string> artifactDigests, CancellationToken ct = default)
|
||||
{
|
||||
var result = new Dictionary<string, SbomInfo>();
|
||||
foreach (var digest in artifactDigests)
|
||||
{
|
||||
if (_sboms.TryGetValue(digest, out var info))
|
||||
{
|
||||
result[digest] = info;
|
||||
}
|
||||
}
|
||||
return Task.FromResult<IReadOnlyDictionary<string, SbomInfo>>(result);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.ReleaseOrchestrator.Release\StellaOps.ReleaseOrchestrator.Release.csproj" />
|
||||
<ProjectReference Include="..\..\..\Attestor\__Libraries\StellaOps.Attestor.StandardPredicates\StellaOps.Attestor.StandardPredicates.csproj" />
|
||||
<ProjectReference Include="..\..\..\Signer\StellaOps.Signer\StellaOps.Signer.Core\StellaOps.Signer.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"parallelizeAssembly": false,
|
||||
"parallelizeTestCollections": true,
|
||||
"maxParallelThreads": -1
|
||||
}
|
||||
@@ -17,6 +17,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using StellaOps.ReleaseOrchestrator.Release.Component;
|
||||
using StellaOps.ReleaseOrchestrator.Release.Events;
|
||||
using StellaOps.ReleaseOrchestrator.Release.Models;
|
||||
using StellaOps.ReleaseOrchestrator.Release.Registry;
|
||||
using StellaOps.ReleaseOrchestrator.Release.Version;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ReleaseOrchestrator.Release.Tests.Version;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="VersionWatcher"/> with proper timer testing using FakeTimeProvider.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class VersionWatcherTests : IAsyncLifetime
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly Mock<IComponentRegistry> _componentRegistryMock;
|
||||
private readonly Mock<IVersionManager> _versionManagerMock;
|
||||
private readonly Mock<IRegistryConnectorFactory> _registryFactoryMock;
|
||||
private readonly Mock<IEventPublisher> _eventPublisherMock;
|
||||
private readonly VersionWatcher _watcher;
|
||||
private readonly TimeSpan _pollInterval = TimeSpan.FromMinutes(5);
|
||||
|
||||
public VersionWatcherTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 18, 12, 0, 0, TimeSpan.Zero));
|
||||
_componentRegistryMock = new Mock<IComponentRegistry>();
|
||||
_versionManagerMock = new Mock<IVersionManager>();
|
||||
_registryFactoryMock = new Mock<IRegistryConnectorFactory>();
|
||||
_eventPublisherMock = new Mock<IEventPublisher>();
|
||||
|
||||
_componentRegistryMock.Setup(x => x.ListActiveAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<Models.Component>());
|
||||
|
||||
_watcher = new VersionWatcher(
|
||||
_componentRegistryMock.Object,
|
||||
_versionManagerMock.Object,
|
||||
_registryFactoryMock.Object,
|
||||
_eventPublisherMock.Object,
|
||||
_timeProvider,
|
||||
NullLogger<VersionWatcher>.Instance,
|
||||
_pollInterval);
|
||||
}
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _watcher.StartAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _watcher.StopAsync(CancellationToken.None);
|
||||
_watcher.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_CompletesImmediately()
|
||||
{
|
||||
// Already started in InitializeAsync - verify no exception
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopAsync_CompletesImmediately()
|
||||
{
|
||||
// Will be stopped in DisposeAsync - verify no exception
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timer_AfterPollInterval_PollsForVersions()
|
||||
{
|
||||
// Arrange
|
||||
var component = CreateComponent(Guid.NewGuid(), "test-component", watchForNewVersions: true);
|
||||
_componentRegistryMock.Setup(x => x.ListActiveAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[] { component });
|
||||
|
||||
var connectorMock = new Mock<IRegistryConnector>();
|
||||
connectorMock.Setup(x => x.ListTagsAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<string>());
|
||||
_registryFactoryMock.Setup(x => x.GetConnectorAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(connectorMock.Object);
|
||||
|
||||
// Act - advance time past initial delay (1 minute)
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1.5));
|
||||
|
||||
// Allow timer callback to execute
|
||||
await Task.Delay(100);
|
||||
|
||||
// Assert
|
||||
_componentRegistryMock.Verify(
|
||||
x => x.ListActiveAsync(It.IsAny<CancellationToken>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timer_ComponentNotWatching_SkipsVersionCheck()
|
||||
{
|
||||
// Arrange
|
||||
var component = CreateComponent(Guid.NewGuid(), "test-component", watchForNewVersions: false);
|
||||
_componentRegistryMock.Setup(x => x.ListActiveAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[] { component });
|
||||
|
||||
// Act - advance time past initial delay
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1.5));
|
||||
|
||||
// Allow timer callback to execute
|
||||
await Task.Delay(100);
|
||||
|
||||
// Assert - should not try to get connector for non-watching component
|
||||
_registryFactoryMock.Verify(
|
||||
x => x.GetConnectorAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PollNowAsync_ForcesImmediatePoll()
|
||||
{
|
||||
// Arrange
|
||||
var component = CreateComponent(Guid.NewGuid(), "test-component", watchForNewVersions: true);
|
||||
_componentRegistryMock.Setup(x => x.ListActiveAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[] { component });
|
||||
|
||||
var connectorMock = new Mock<IRegistryConnector>();
|
||||
connectorMock.Setup(x => x.ListTagsAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<string>());
|
||||
_registryFactoryMock.Setup(x => x.GetConnectorAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(connectorMock.Object);
|
||||
|
||||
// Act - call PollNowAsync directly without waiting for timer
|
||||
await _watcher.PollNowAsync();
|
||||
|
||||
// Assert
|
||||
_componentRegistryMock.Verify(
|
||||
x => x.ListActiveAsync(It.IsAny<CancellationToken>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timer_MultipleIntervals_PollsMultipleTimes()
|
||||
{
|
||||
// Arrange
|
||||
var component = CreateComponent(Guid.NewGuid(), "test-component", watchForNewVersions: true);
|
||||
_componentRegistryMock.Setup(x => x.ListActiveAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[] { component });
|
||||
|
||||
var connectorMock = new Mock<IRegistryConnector>();
|
||||
connectorMock.Setup(x => x.ListTagsAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<string>());
|
||||
_registryFactoryMock.Setup(x => x.GetConnectorAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(connectorMock.Object);
|
||||
|
||||
// Act - advance through multiple poll intervals
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1.5));
|
||||
await Task.Delay(50);
|
||||
_timeProvider.Advance(_pollInterval);
|
||||
await Task.Delay(50);
|
||||
_timeProvider.Advance(_pollInterval);
|
||||
await Task.Delay(50);
|
||||
|
||||
// Assert - should have polled multiple times
|
||||
_componentRegistryMock.Verify(
|
||||
x => x.ListActiveAsync(It.IsAny<CancellationToken>()),
|
||||
Times.AtLeast(2));
|
||||
}
|
||||
|
||||
private static Models.Component CreateComponent(Guid id, string name, bool watchForNewVersions) => new()
|
||||
{
|
||||
Id = id,
|
||||
TenantId = Guid.NewGuid(),
|
||||
Name = name,
|
||||
DisplayName = name,
|
||||
Repository = "docker.io/library/test",
|
||||
RegistryUrl = "https://registry-1.docker.io",
|
||||
Status = ComponentStatus.Active,
|
||||
Config = new ComponentConfig
|
||||
{
|
||||
WatchForNewVersions = watchForNewVersions
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -132,6 +132,9 @@ public sealed class StepTimeoutHandlerTests : IDisposable
|
||||
// Act
|
||||
await _handler.StartAsync(CancellationToken.None);
|
||||
|
||||
// Cleanup - must stop to avoid timer leak
|
||||
await _handler.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert - should not throw
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.ReleaseOrchestrator.Workflow\StellaOps.ReleaseOrchestrator.Workflow.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -58,7 +58,9 @@ public sealed class WaitStepProviderTests
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _provider.ExecuteAsync(context);
|
||||
var execution = _provider.ExecuteAsync(context);
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
var result = await execution;
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(StepResultStatus.Succeeded);
|
||||
@@ -77,7 +79,9 @@ public sealed class WaitStepProviderTests
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _provider.ExecuteAsync(context);
|
||||
var execution = _provider.ExecuteAsync(context);
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(2));
|
||||
var result = await execution;
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(StepResultStatus.Succeeded);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"parallelizeTestCollections": false,
|
||||
"maxParallelThreads": 1
|
||||
}
|
||||
Reference in New Issue
Block a user