docs re-org, audit fixes, build fixes

This commit is contained in:
StellaOps Bot
2026-01-05 09:35:33 +02:00
parent eca4e964d3
commit dfab8a29c3
173 changed files with 1276 additions and 560 deletions

View File

@@ -72,6 +72,11 @@ public sealed class HeaderScopeAuthenticationHandler : AuthenticationHandler<Aut
foreach (var value in values)
{
if (string.IsNullOrEmpty(value))
{
continue;
}
foreach (var scope in value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
scopes.Add(scope);

View File

@@ -72,7 +72,8 @@ public sealed class AirGapIntegrationTests : IDisposable
null,
new[] { new FeedBuildConfig("nvd-feed", "nvd", "2025-06-15", feedPath, "feeds/nvd.json", DateTimeOffset.UtcNow, FeedFormat.StellaOpsNative) },
Array.Empty<PolicyBuildConfig>(),
Array.Empty<CryptoBuildConfig>());
Array.Empty<CryptoBuildConfig>(),
Array.Empty<RuleBundleBuildConfig>());
var bundleOutputPath = Path.Combine(_onlineEnvPath, "bundle");
@@ -120,7 +121,8 @@ public sealed class AirGapIntegrationTests : IDisposable
DateTimeOffset.UtcNow.AddDays(30),
new[] { new FeedBuildConfig("feed-1", "nvd", "v1", feedPath, "feeds/all-feeds.json", DateTimeOffset.UtcNow, FeedFormat.StellaOpsNative) },
new[] { new PolicyBuildConfig("policy-1", "default", "1.0", policyPath, "policies/default.rego", PolicyType.OpaRego) },
new[] { new CryptoBuildConfig("crypto-1", "trust-root", certPath, "certs/root.pem", CryptoComponentType.TrustRoot, null) });
new[] { new CryptoBuildConfig("crypto-1", "trust-root", certPath, "certs/root.pem", CryptoComponentType.TrustRoot, null) },
Array.Empty<RuleBundleBuildConfig>());
var bundlePath = Path.Combine(_onlineEnvPath, "multi-bundle");
@@ -161,7 +163,8 @@ public sealed class AirGapIntegrationTests : IDisposable
null,
new[] { new FeedBuildConfig("feed", "nvd", "v1", feedPath, "feeds/nvd.json", DateTimeOffset.UtcNow, FeedFormat.StellaOpsNative) },
Array.Empty<PolicyBuildConfig>(),
Array.Empty<CryptoBuildConfig>());
Array.Empty<CryptoBuildConfig>(),
Array.Empty<RuleBundleBuildConfig>());
var bundlePath = Path.Combine(_onlineEnvPath, "corrupt-source");
var manifest = await builder.BuildAsync(request, bundlePath);
@@ -219,7 +222,8 @@ public sealed class AirGapIntegrationTests : IDisposable
null,
Array.Empty<FeedBuildConfig>(),
new[] { new PolicyBuildConfig("security-policy", "security", "1.0", policyPath, "policies/security.rego", PolicyType.OpaRego) },
Array.Empty<CryptoBuildConfig>());
Array.Empty<CryptoBuildConfig>(),
Array.Empty<RuleBundleBuildConfig>());
var bundlePath = Path.Combine(_onlineEnvPath, "policy-bundle");
@@ -273,7 +277,8 @@ public sealed class AirGapIntegrationTests : IDisposable
new PolicyBuildConfig("policy-2", "policy2", "1.0", policy2Path, "policies/policy2.rego", PolicyType.OpaRego),
new PolicyBuildConfig("policy-3", "policy3", "1.0", policy3Path, "policies/policy3.rego", PolicyType.OpaRego)
},
Array.Empty<CryptoBuildConfig>());
Array.Empty<CryptoBuildConfig>(),
Array.Empty<RuleBundleBuildConfig>());
var bundlePath = Path.Combine(_onlineEnvPath, "multi-policy");
@@ -315,7 +320,8 @@ public sealed class AirGapIntegrationTests : IDisposable
null,
Array.Empty<FeedBuildConfig>(),
new[] { new PolicyBuildConfig("signed-policy", "signed", "1.0", policyPath, "policies/signed.rego", PolicyType.OpaRego) },
new[] { new CryptoBuildConfig("signing-cert", "signing", certPath, "certs/signing.pem", CryptoComponentType.SigningKey, null) });
new[] { new CryptoBuildConfig("signing-cert", "signing", certPath, "certs/signing.pem", CryptoComponentType.SigningKey, null) },
Array.Empty<RuleBundleBuildConfig>());
var bundlePath = Path.Combine(_onlineEnvPath, "signed-bundle");

View File

@@ -142,7 +142,8 @@ public sealed class BundleDeterminismTests : IAsyncLifetime
new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero), FeedFormat.StellaOpsNative)
},
Array.Empty<PolicyBuildConfig>(),
Array.Empty<CryptoBuildConfig>());
Array.Empty<CryptoBuildConfig>(),
Array.Empty<RuleBundleBuildConfig>());
// Act - First export
var manifest1 = await builder.BuildAsync(request, outputPath1);
@@ -163,7 +164,8 @@ public sealed class BundleDeterminismTests : IAsyncLifetime
new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero), FeedFormat.StellaOpsNative)
},
Array.Empty<PolicyBuildConfig>(),
Array.Empty<CryptoBuildConfig>());
Array.Empty<CryptoBuildConfig>(),
Array.Empty<RuleBundleBuildConfig>());
var manifest2 = await builder.BuildAsync(request2, outputPath2);
@@ -278,7 +280,8 @@ public sealed class BundleDeterminismTests : IAsyncLifetime
new FeedBuildConfig("f3", "osv", "v1", feed3, "feeds/f3.json", DateTimeOffset.UtcNow, FeedFormat.OsvJson)
},
Array.Empty<PolicyBuildConfig>(),
Array.Empty<CryptoBuildConfig>());
Array.Empty<CryptoBuildConfig>(),
Array.Empty<RuleBundleBuildConfig>());
// Act
var manifest = await builder.BuildAsync(request, Path.Combine(_tempRoot, "multi"));
@@ -332,7 +335,8 @@ public sealed class BundleDeterminismTests : IAsyncLifetime
new FeedBuildConfig("f1", "binary", "v1", source1, "data/binary.bin", DateTimeOffset.UtcNow, FeedFormat.StellaOpsNative)
},
Array.Empty<PolicyBuildConfig>(),
Array.Empty<CryptoBuildConfig>());
Array.Empty<CryptoBuildConfig>(),
Array.Empty<RuleBundleBuildConfig>());
var request2 = new BundleBuildRequest(
"binary-test",
@@ -343,7 +347,8 @@ public sealed class BundleDeterminismTests : IAsyncLifetime
new FeedBuildConfig("f1", "binary", "v1", source2, "data/binary.bin", DateTimeOffset.UtcNow, FeedFormat.StellaOpsNative)
},
Array.Empty<PolicyBuildConfig>(),
Array.Empty<CryptoBuildConfig>());
Array.Empty<CryptoBuildConfig>(),
Array.Empty<RuleBundleBuildConfig>());
// Act
var manifest1 = await builder.BuildAsync(request1, Path.Combine(_tempRoot, "bin1"));
@@ -407,7 +412,8 @@ public sealed class BundleDeterminismTests : IAsyncLifetime
new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero), FeedFormat.StellaOpsNative)
},
Array.Empty<PolicyBuildConfig>(),
Array.Empty<CryptoBuildConfig>());
Array.Empty<CryptoBuildConfig>(),
Array.Empty<RuleBundleBuildConfig>());
}
private BundleManifest CreateDeterministicManifest(string name)

View File

@@ -259,7 +259,8 @@ public sealed class BundleExportImportTests : IDisposable
null,
new[] { new FeedBuildConfig("feed-1", "nvd", "v1", feedFile1, "feeds/nvd.json", DateTimeOffset.Parse("2025-01-01T00:00:00Z"), FeedFormat.StellaOpsNative) },
Array.Empty<PolicyBuildConfig>(),
Array.Empty<CryptoBuildConfig>());
Array.Empty<CryptoBuildConfig>(),
Array.Empty<RuleBundleBuildConfig>());
var request2 = new BundleBuildRequest(
"determinism-test",
@@ -267,7 +268,8 @@ public sealed class BundleExportImportTests : IDisposable
null,
new[] { new FeedBuildConfig("feed-1", "nvd", "v1", feedFile2, "feeds/nvd.json", DateTimeOffset.Parse("2025-01-01T00:00:00Z"), FeedFormat.StellaOpsNative) },
Array.Empty<PolicyBuildConfig>(),
Array.Empty<CryptoBuildConfig>());
Array.Empty<CryptoBuildConfig>(),
Array.Empty<RuleBundleBuildConfig>());
var outputPath1 = Path.Combine(_tempRoot, "determinism-output1");
var outputPath2 = Path.Combine(_tempRoot, "determinism-output2");
@@ -363,7 +365,8 @@ public sealed class BundleExportImportTests : IDisposable
imported.Feeds[0].SnapshotAt,
imported.Feeds[0].Format) },
Array.Empty<PolicyBuildConfig>(),
Array.Empty<CryptoBuildConfig>());
Array.Empty<CryptoBuildConfig>(),
Array.Empty<RuleBundleBuildConfig>());
var bundlePath2 = Path.Combine(_tempRoot, "roundtrip2");
var manifest2 = await builder.BuildAsync(reexportRequest, bundlePath2);
@@ -409,7 +412,8 @@ public sealed class BundleExportImportTests : IDisposable
null,
new[] { new FeedBuildConfig("feed-1", "nvd", "v1", feedSourcePath, "feeds/nvd.json", DateTimeOffset.UtcNow, FeedFormat.StellaOpsNative) },
Array.Empty<PolicyBuildConfig>(),
Array.Empty<CryptoBuildConfig>());
Array.Empty<CryptoBuildConfig>(),
Array.Empty<RuleBundleBuildConfig>());
}
private static BundleManifest CreateTestManifest()

View File

@@ -49,7 +49,8 @@ public sealed class BundleExportTests : IAsyncLifetime
null,
Array.Empty<FeedBuildConfig>(),
Array.Empty<PolicyBuildConfig>(),
Array.Empty<CryptoBuildConfig>());
Array.Empty<CryptoBuildConfig>(),
Array.Empty<RuleBundleBuildConfig>());
// Act
var manifest = await builder.BuildAsync(request, outputPath);
@@ -93,7 +94,8 @@ public sealed class BundleExportTests : IAsyncLifetime
FeedFormat.StellaOpsNative)
},
Array.Empty<PolicyBuildConfig>(),
Array.Empty<CryptoBuildConfig>());
Array.Empty<CryptoBuildConfig>(),
Array.Empty<RuleBundleBuildConfig>());
// Act
var manifest = await builder.BuildAsync(request, outputPath);
@@ -139,7 +141,8 @@ public sealed class BundleExportTests : IAsyncLifetime
"policies/default.rego",
PolicyType.OpaRego)
},
Array.Empty<CryptoBuildConfig>());
Array.Empty<CryptoBuildConfig>(),
Array.Empty<RuleBundleBuildConfig>());
// Act
var manifest = await builder.BuildAsync(request, outputPath);
@@ -182,7 +185,8 @@ public sealed class BundleExportTests : IAsyncLifetime
"certs/root.pem",
CryptoComponentType.TrustRoot,
DateTimeOffset.UtcNow.AddYears(10))
});
},
Array.Empty<RuleBundleBuildConfig>());
// Act
var manifest = await builder.BuildAsync(request, outputPath);
@@ -225,7 +229,8 @@ public sealed class BundleExportTests : IAsyncLifetime
{
new PolicyBuildConfig("p1", "default", "1.0", policy, "policies/default.rego", PolicyType.OpaRego)
},
Array.Empty<CryptoBuildConfig>());
Array.Empty<CryptoBuildConfig>(),
Array.Empty<RuleBundleBuildConfig>());
// Act
var manifest = await builder.BuildAsync(request, outputPath);
@@ -261,7 +266,8 @@ public sealed class BundleExportTests : IAsyncLifetime
new FeedBuildConfig("f1", "test", "v1", feedFile, "feeds/test.json", DateTimeOffset.UtcNow, FeedFormat.StellaOpsNative)
},
Array.Empty<PolicyBuildConfig>(),
Array.Empty<CryptoBuildConfig>());
Array.Empty<CryptoBuildConfig>(),
Array.Empty<RuleBundleBuildConfig>());
// Act
var manifest = await builder.BuildAsync(request, outputPath);
@@ -288,7 +294,8 @@ public sealed class BundleExportTests : IAsyncLifetime
new FeedBuildConfig("f1", "test", "v1", feedFile, "feeds/test.json", DateTimeOffset.UtcNow, FeedFormat.StellaOpsNative)
},
Array.Empty<PolicyBuildConfig>(),
Array.Empty<CryptoBuildConfig>());
Array.Empty<CryptoBuildConfig>(),
Array.Empty<RuleBundleBuildConfig>());
// Act
var manifest = await builder.BuildAsync(request, outputPath);
@@ -328,7 +335,8 @@ public sealed class BundleExportTests : IAsyncLifetime
new[]
{
new CryptoBuildConfig("c1", "root", certFile, "crypto/certs/ca/root.pem", CryptoComponentType.TrustRoot, null)
});
},
Array.Empty<RuleBundleBuildConfig>());
// Act
var manifest = await builder.BuildAsync(request, outputPath);
@@ -369,7 +377,8 @@ public sealed class BundleExportTests : IAsyncLifetime
new FeedBuildConfig("f1", "test", "v1", feedFile, "feeds/test.json", DateTimeOffset.UtcNow, format)
},
Array.Empty<PolicyBuildConfig>(),
Array.Empty<CryptoBuildConfig>());
Array.Empty<CryptoBuildConfig>(),
Array.Empty<RuleBundleBuildConfig>());
// Act
var manifest = await builder.BuildAsync(request, outputPath);
@@ -404,7 +413,8 @@ public sealed class BundleExportTests : IAsyncLifetime
{
new PolicyBuildConfig("p1", "test", "1.0", policyFile, "policies/test", type)
},
Array.Empty<CryptoBuildConfig>());
Array.Empty<CryptoBuildConfig>(),
Array.Empty<RuleBundleBuildConfig>());
// Act
var manifest = await builder.BuildAsync(request, outputPath);
@@ -440,7 +450,8 @@ public sealed class BundleExportTests : IAsyncLifetime
new[]
{
new CryptoBuildConfig("c1", "test", certFile, "certs/test", type, null)
});
},
Array.Empty<RuleBundleBuildConfig>());
// Act
var manifest = await builder.BuildAsync(request, outputPath);
@@ -468,7 +479,8 @@ public sealed class BundleExportTests : IAsyncLifetime
expiresAt,
Array.Empty<FeedBuildConfig>(),
Array.Empty<PolicyBuildConfig>(),
Array.Empty<CryptoBuildConfig>());
Array.Empty<CryptoBuildConfig>(),
Array.Empty<RuleBundleBuildConfig>());
// Act
var manifest = await builder.BuildAsync(request, outputPath);
@@ -496,7 +508,8 @@ public sealed class BundleExportTests : IAsyncLifetime
new[]
{
new CryptoBuildConfig("c1", "root", certFile, "certs/root.pem", CryptoComponentType.TrustRoot, componentExpiry)
});
},
Array.Empty<RuleBundleBuildConfig>());
// Act
var manifest = await builder.BuildAsync(request, outputPath);

View File

@@ -49,7 +49,8 @@ public class BundleManifestTests
null,
new[] { new FeedBuildConfig("feed-1", "nvd", "v1", sourceFile, "feeds/nvd.json", DateTimeOffset.UtcNow, FeedFormat.StellaOpsNative) },
Array.Empty<PolicyBuildConfig>(),
Array.Empty<CryptoBuildConfig>());
Array.Empty<CryptoBuildConfig>(),
Array.Empty<RuleBundleBuildConfig>());
var outputPath = Path.Combine(tempRoot, "bundle");
var manifest = await builder.BuildAsync(request, outputPath);

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang/StellaOps.Scanner.Analyzers.Lang.csproj" />
<ProjectReference Include="../../../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Node/StellaOps.Scanner.Analyzers.Lang.Node.csproj" />
<ProjectReference Include="../../../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Go/StellaOps.Scanner.Analyzers.Lang.Go.csproj" />
<ProjectReference Include="../../../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Java/StellaOps.Scanner.Analyzers.Lang.Java.csproj" />
<ProjectReference Include="../../../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Python/StellaOps.Scanner.Analyzers.Lang.Python.csproj" />
</ItemGroup>
</Project>

View File

@@ -26,10 +26,12 @@ internal sealed class ForensicVerifier : IForensicVerifier
};
private readonly ILogger<ForensicVerifier> _logger;
private readonly TimeProvider _timeProvider;
public ForensicVerifier(ILogger<ForensicVerifier> logger)
public ForensicVerifier(ILogger<ForensicVerifier> logger, TimeProvider? timeProvider = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<ForensicVerificationResult> VerifyBundleAsync(
@@ -42,7 +44,7 @@ internal sealed class ForensicVerifier : IForensicVerifier
var errors = new List<ForensicVerificationError>();
var warnings = new List<string>();
var verifiedAt = DateTimeOffset.UtcNow;
var verifiedAt = _timeProvider.GetUtcNow();
_logger.LogDebug("Verifying forensic bundle at {BundlePath}", bundlePath);
@@ -440,7 +442,7 @@ internal sealed class ForensicVerifier : IForensicVerifier
matchingRoot.PublicKey);
// Check time validity
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
var timeValid = (!matchingRoot.NotBefore.HasValue || now >= matchingRoot.NotBefore.Value) &&
(!matchingRoot.NotAfter.HasValue || now <= matchingRoot.NotAfter.Value);

View File

@@ -17,17 +17,20 @@ public sealed class ImageAttestationVerifier : IImageAttestationVerifier
private readonly ITrustPolicyLoader _trustPolicyLoader;
private readonly IDsseSignatureVerifier _dsseVerifier;
private readonly ILogger<ImageAttestationVerifier> _logger;
private readonly TimeProvider _timeProvider;
public ImageAttestationVerifier(
IOciRegistryClient registryClient,
ITrustPolicyLoader trustPolicyLoader,
IDsseSignatureVerifier dsseVerifier,
ILogger<ImageAttestationVerifier> logger)
ILogger<ImageAttestationVerifier> logger,
TimeProvider? timeProvider = null)
{
_registryClient = registryClient ?? throw new ArgumentNullException(nameof(registryClient));
_trustPolicyLoader = trustPolicyLoader ?? throw new ArgumentNullException(nameof(trustPolicyLoader));
_dsseVerifier = dsseVerifier ?? throw new ArgumentNullException(nameof(dsseVerifier));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<ImageVerificationResult> VerifyAsync(
@@ -51,7 +54,7 @@ public sealed class ImageAttestationVerifier : IImageAttestationVerifier
ImageDigest = digest,
Registry = reference.Registry,
Repository = reference.Repository,
VerifiedAt = DateTimeOffset.UtcNow
VerifiedAt = _timeProvider.GetUtcNow()
};
OciReferrersResponse referrers;
@@ -191,7 +194,7 @@ public sealed class ImageAttestationVerifier : IImageAttestationVerifier
Digest = candidate.Digest,
SignerIdentity = verification.KeyId,
Message = verification.Error ?? "Signature verification failed",
VerifiedAt = DateTimeOffset.UtcNow
VerifiedAt = _timeProvider.GetUtcNow()
};
}
@@ -206,7 +209,7 @@ public sealed class ImageAttestationVerifier : IImageAttestationVerifier
Digest = candidate.Digest,
SignerIdentity = signerKeyId,
Message = "Signer not allowed by trust policy",
VerifiedAt = DateTimeOffset.UtcNow
VerifiedAt = _timeProvider.GetUtcNow()
};
}
@@ -220,14 +223,14 @@ public sealed class ImageAttestationVerifier : IImageAttestationVerifier
Digest = candidate.Digest,
SignerIdentity = signerKeyId,
Message = "Rekor receipt missing",
VerifiedAt = DateTimeOffset.UtcNow
VerifiedAt = _timeProvider.GetUtcNow()
};
}
if (policy.MaxAge.HasValue)
{
var created = GetCreatedAt(candidate);
if (created.HasValue && DateTimeOffset.UtcNow - created.Value > policy.MaxAge.Value)
if (created.HasValue && _timeProvider.GetUtcNow() - created.Value > policy.MaxAge.Value)
{
return new AttestationVerification
{
@@ -237,7 +240,7 @@ public sealed class ImageAttestationVerifier : IImageAttestationVerifier
Digest = candidate.Digest,
SignerIdentity = signerKeyId,
Message = "Attestation exceeded max age",
VerifiedAt = DateTimeOffset.UtcNow
VerifiedAt = _timeProvider.GetUtcNow()
};
}
}
@@ -250,7 +253,7 @@ public sealed class ImageAttestationVerifier : IImageAttestationVerifier
Digest = candidate.Digest,
SignerIdentity = signerKeyId,
Message = "Signature valid",
VerifiedAt = DateTimeOffset.UtcNow
VerifiedAt = _timeProvider.GetUtcNow()
};
}
catch (Exception ex)

View File

@@ -76,7 +76,7 @@ public sealed class CertFrFeedClient
}
var advisoryId = ResolveAdvisoryId(itemElement, detailUri);
items.Add(new CertFrFeedItem(advisoryId, detailUri, published.ToUniversalTime(), title, summary));
items.Add(new CertFrFeedItem(advisoryId, detailUri, published.Value.ToUniversalTime(), title, summary));
}
_diagnostics.FeedFetchSuccess(items.Count);

View File

@@ -73,6 +73,7 @@ public sealed class CachePerformanceBenchmarkTests : IAsyncLifetime
_cacheService = new ValkeyAdvisoryCacheService(
_connectionFactory,
options,
metrics: null,
NullLogger<ValkeyAdvisoryCacheService>.Instance);
await ValueTask.CompletedTask;

View File

@@ -9,13 +9,16 @@ public sealed class ExportRetentionService : IExportRetentionService
{
private readonly IExportRetentionStore _retentionStore;
private readonly ILogger<ExportRetentionService> _logger;
private readonly TimeProvider _timeProvider;
public ExportRetentionService(
IExportRetentionStore retentionStore,
ILogger<ExportRetentionService> logger)
ILogger<ExportRetentionService> logger,
TimeProvider? timeProvider = null)
{
_retentionStore = retentionStore ?? throw new ArgumentNullException(nameof(retentionStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
@@ -26,7 +29,7 @@ public sealed class ExportRetentionService : IExportRetentionService
ArgumentNullException.ThrowIfNull(request);
var retention = request.OverrideRetention ?? new ExportRetentionConfig();
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
_logger.LogInformation(
"Starting retention prune for tenant {TenantId}, profile {ProfileId}, execute={Execute}",

View File

@@ -11,6 +11,12 @@ public sealed class InMemoryExportScheduleStore : IExportScheduleStore
private readonly ConcurrentDictionary<Guid, Guid> _runToProfile = new();
private readonly ConcurrentDictionary<Guid, List<ScheduledProfileInfo>> _profilesByTenant = new();
private readonly object _lock = new();
private readonly TimeProvider _timeProvider;
public InMemoryExportScheduleStore(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// Adds a profile for testing.
@@ -106,7 +112,7 @@ public sealed class InMemoryExportScheduleStore : IExportScheduleStore
{
if (_statusByProfile.TryGetValue(profileId, out var existing))
{
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
var newFailureCount = success ? 0 : existing.ConsecutiveFailures + 1;
_statusByProfile[profileId] = existing with

View File

@@ -32,12 +32,14 @@ public sealed class LineageEvidencePackService : ILineageEvidencePackService
};
private readonly ILogger<LineageEvidencePackService> _logger;
private readonly TimeProvider _timeProvider;
private readonly ConcurrentDictionary<Guid, CachedPack> _packCache = new();
private readonly string _tempDirectory;
public LineageEvidencePackService(ILogger<LineageEvidencePackService> logger)
public LineageEvidencePackService(ILogger<LineageEvidencePackService> logger, TimeProvider? timeProvider = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
_tempDirectory = Path.Combine(Path.GetTempPath(), "stellaops-evidence-packs");
Directory.CreateDirectory(_tempDirectory);
}
@@ -187,7 +189,7 @@ public sealed class LineageEvidencePackService : ILineageEvidencePackService
Entries = entries.ToImmutableArray(),
TotalSizeBytes = entries.Sum(e => e.SizeBytes),
FileCount = entries.Count,
CreatedAt = DateTimeOffset.UtcNow
CreatedAt = _timeProvider.GetUtcNow()
};
// Write manifest
@@ -205,7 +207,7 @@ public sealed class LineageEvidencePackService : ILineageEvidencePackService
VexVerdictDigests = vexDocuments.Select(v => v.Digest).ToImmutableArray(),
PolicyVerdictDigest = policyVerdict?.Digest,
ReplayHash = ComputeReplayHash(artifactDigest, sbomDigest, manifest.MerkleRoot),
GeneratedAt = DateTimeOffset.UtcNow,
GeneratedAt = _timeProvider.GetUtcNow(),
Attestations = attestations.ToImmutableArray(),
SbomDocuments = sbomDocuments.ToImmutableArray(),
VexDocuments = vexDocuments.ToImmutableArray(),
@@ -224,7 +226,7 @@ public sealed class LineageEvidencePackService : ILineageEvidencePackService
{
Pack = pack,
ZipPath = zipPath,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24)
ExpiresAt = _timeProvider.GetUtcNow().AddHours(24)
};
// Clean up temp directory
@@ -246,7 +248,7 @@ public sealed class LineageEvidencePackService : ILineageEvidencePackService
Success = true,
Pack = pack,
DownloadUrl = $"/api/v1/lineage/export/{packId}/download",
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24),
ExpiresAt = _timeProvider.GetUtcNow().AddHours(24),
SizeBytes = zipInfo.Length,
Warnings = warnings.ToImmutableArray()
};
@@ -268,7 +270,7 @@ public sealed class LineageEvidencePackService : ILineageEvidencePackService
string tenantId,
CancellationToken ct = default)
{
if (_packCache.TryGetValue(packId, out var cached) && cached.ExpiresAt > DateTimeOffset.UtcNow)
if (_packCache.TryGetValue(packId, out var cached) && cached.ExpiresAt > _timeProvider.GetUtcNow())
{
if (cached.Pack.TenantId == tenantId)
{
@@ -285,7 +287,7 @@ public sealed class LineageEvidencePackService : ILineageEvidencePackService
string tenantId,
CancellationToken ct = default)
{
if (_packCache.TryGetValue(packId, out var cached) && cached.ExpiresAt > DateTimeOffset.UtcNow)
if (_packCache.TryGetValue(packId, out var cached) && cached.ExpiresAt > _timeProvider.GetUtcNow())
{
if (cached.Pack.TenantId == tenantId && File.Exists(cached.ZipPath))
{
@@ -347,7 +349,7 @@ public sealed class LineageEvidencePackService : ILineageEvidencePackService
bomFormat = "CycloneDX",
specVersion = "1.6",
version = 1,
metadata = new { timestamp = DateTimeOffset.UtcNow.ToString("O") },
metadata = new { timestamp = _timeProvider.GetUtcNow().ToString("O") },
components = Array.Empty<object>()
}, JsonOptions);
@@ -383,7 +385,7 @@ public sealed class LineageEvidencePackService : ILineageEvidencePackService
dataLicense = "CC0-1.0",
name = artifactDigest,
documentNamespace = $"https://stellaops.io/spdx/{artifactDigest}",
creationInfo = new { created = DateTimeOffset.UtcNow.ToString("O") },
creationInfo = new { created = _timeProvider.GetUtcNow().ToString("O") },
packages = Array.Empty<object>()
}, JsonOptions);
@@ -418,7 +420,7 @@ public sealed class LineageEvidencePackService : ILineageEvidencePackService
context = "https://openvex.dev/ns/v0.2.0",
id = $"urn:stellaops:vex:{artifactDigest}",
author = "StellaOps",
timestamp = DateTimeOffset.UtcNow.ToString("O"),
timestamp = _timeProvider.GetUtcNow().ToString("O"),
statements = Array.Empty<object>()
}, JsonOptions);
@@ -457,7 +459,7 @@ public sealed class LineageEvidencePackService : ILineageEvidencePackService
tenantId,
verdict = "pass",
policyVersion = "1.0.0",
evaluatedAt = DateTimeOffset.UtcNow.ToString("O"),
evaluatedAt = _timeProvider.GetUtcNow().ToString("O"),
rules = new { total = 0, passed = 0, failed = 0, warned = 0 }
}, JsonOptions);
@@ -477,7 +479,7 @@ public sealed class LineageEvidencePackService : ILineageEvidencePackService
RulesPassed = 0,
RulesFailed = 0,
RulesWarned = 0,
EvaluatedAt = DateTimeOffset.UtcNow,
EvaluatedAt = _timeProvider.GetUtcNow(),
FileName = fileName
};
}
@@ -528,9 +530,9 @@ public sealed class LineageEvidencePackService : ILineageEvidencePackService
return ComputeHash(combined);
}
private static string ComputeReplayHash(string artifactDigest, string sbomDigest, string merkleRoot)
private string ComputeReplayHash(string artifactDigest, string sbomDigest, string merkleRoot)
{
var input = $"{artifactDigest}|{sbomDigest}|{merkleRoot}|{DateTimeOffset.UtcNow:O}";
var input = $"{artifactDigest}|{sbomDigest}|{merkleRoot}|{_timeProvider.GetUtcNow():O}";
return $"sha256:{ComputeHash(input)}";
}

View File

@@ -149,14 +149,15 @@ public sealed record ExportVerificationResult
/// <summary>
/// When verification was performed.
/// </summary>
public DateTimeOffset VerifiedAt { get; init; } = DateTimeOffset.UtcNow;
public required DateTimeOffset VerifiedAt { get; init; }
public static ExportVerificationResult Failed(Guid runId, params VerificationError[] errors)
public static ExportVerificationResult Failed(Guid runId, DateTimeOffset verifiedAt, params VerificationError[] errors)
=> new()
{
Status = VerificationStatus.Invalid,
RunId = runId,
Errors = errors
Errors = errors,
VerifiedAt = verifiedAt
};
}

View File

@@ -14,22 +14,26 @@ public sealed class ExportVerificationService : IExportVerificationService
private readonly IExportArtifactStore _artifactStore;
private readonly IPackRunAttestationStore? _packRunStore;
private readonly ILogger<ExportVerificationService> _logger;
private readonly TimeProvider _timeProvider;
public ExportVerificationService(
IExportArtifactStore artifactStore,
ILogger<ExportVerificationService> logger)
: this(artifactStore, null, logger)
ILogger<ExportVerificationService> logger,
TimeProvider? timeProvider = null)
: this(artifactStore, null, logger, timeProvider)
{
}
public ExportVerificationService(
IExportArtifactStore artifactStore,
IPackRunAttestationStore? packRunStore,
ILogger<ExportVerificationService> logger)
ILogger<ExportVerificationService> logger,
TimeProvider? timeProvider = null)
{
_artifactStore = artifactStore ?? throw new ArgumentNullException(nameof(artifactStore));
_packRunStore = packRunStore;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
@@ -52,6 +56,7 @@ public sealed class ExportVerificationService : IExportVerificationService
{
return ExportVerificationResult.Failed(
request.RunId,
_timeProvider.GetUtcNow(),
new VerificationError
{
Code = VerificationErrorCodes.ManifestNotFound,
@@ -64,6 +69,7 @@ public sealed class ExportVerificationService : IExportVerificationService
{
return ExportVerificationResult.Failed(
request.RunId,
_timeProvider.GetUtcNow(),
new VerificationError
{
Code = VerificationErrorCodes.TenantMismatch,
@@ -234,7 +240,8 @@ public sealed class ExportVerificationService : IExportVerificationService
Encryption = encryptionResult,
Attestation = attestationStatus,
Errors = errors,
Warnings = warnings
Warnings = warnings,
VerifiedAt = _timeProvider.GetUtcNow()
};
}

View File

@@ -19,6 +19,7 @@ public sealed class ExceptionReportGenerator : IExceptionReportGenerator
private readonly IExceptionApplicationRepository _applicationRepository;
private readonly ConcurrentDictionary<string, ReportJob> _jobs = new();
private readonly ILogger<ExceptionReportGenerator> _logger;
private readonly TimeProvider _timeProvider;
private static readonly JsonSerializerOptions JsonOptions = new()
{
@@ -30,11 +31,13 @@ public sealed class ExceptionReportGenerator : IExceptionReportGenerator
public ExceptionReportGenerator(
IExceptionRepository exceptionRepository,
IExceptionApplicationRepository applicationRepository,
ILogger<ExceptionReportGenerator> logger)
ILogger<ExceptionReportGenerator> logger,
TimeProvider? timeProvider = null)
{
_exceptionRepository = exceptionRepository;
_applicationRepository = applicationRepository;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<ExceptionReportJobResponse> CreateReportAsync(
@@ -42,7 +45,7 @@ public sealed class ExceptionReportGenerator : IExceptionReportGenerator
CancellationToken cancellationToken = default)
{
var jobId = $"exc-rpt-{Guid.NewGuid():N}";
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
var job = new ReportJob
{
@@ -151,7 +154,7 @@ public sealed class ExceptionReportGenerator : IExceptionReportGenerator
try
{
job.Status = "running";
job.StartedAt = DateTimeOffset.UtcNow;
job.StartedAt = _timeProvider.GetUtcNow();
var filter = job.Request.Filter ?? new ExceptionFilter
{
@@ -232,7 +235,7 @@ public sealed class ExceptionReportGenerator : IExceptionReportGenerator
var document = new ExceptionReportDocument
{
ReportId = job.JobId,
GeneratedAt = DateTimeOffset.UtcNow,
GeneratedAt = _timeProvider.GetUtcNow(),
TenantId = job.TenantId,
RequesterId = job.RequesterId,
Title = job.Request.Title ?? "Exception Report",
@@ -289,7 +292,7 @@ public sealed class ExceptionReportGenerator : IExceptionReportGenerator
job.ContentHash = $"sha256:{Convert.ToHexString(SHA256.HashData(content)).ToLowerInvariant()}";
job.Progress = 100;
job.Status = "completed";
job.CompletedAt = DateTimeOffset.UtcNow;
job.CompletedAt = _timeProvider.GetUtcNow();
_logger.LogInformation(
"Completed exception report {JobId} with {Count} exceptions, {Size} bytes",
@@ -300,7 +303,7 @@ public sealed class ExceptionReportGenerator : IExceptionReportGenerator
_logger.LogError(ex, "Failed to generate exception report {JobId}", job.JobId);
job.Status = "failed";
job.ErrorMessage = ex.Message;
job.CompletedAt = DateTimeOffset.UtcNow;
job.CompletedAt = _timeProvider.GetUtcNow();
}
}

View File

@@ -6,11 +6,12 @@ namespace StellaOps.Findings.Ledger.WebService.Mappings;
public static class LedgerEventMapping
{
public static LedgerEventDraft ToDraft(this LedgerEventRequest request)
public static LedgerEventDraft ToDraft(this LedgerEventRequest request, TimeProvider? timeProvider = null)
{
ArgumentNullException.ThrowIfNull(request);
var recordedAt = (request.RecordedAt ?? DateTimeOffset.UtcNow).ToUniversalTime();
timeProvider ??= TimeProvider.System;
var recordedAt = (request.RecordedAt ?? timeProvider.GetUtcNow()).ToUniversalTime();
var payload = request.Payload is null ? new JsonObject() : (JsonObject)request.Payload.DeepClone();
var eventObject = new JsonObject

View File

@@ -1768,6 +1768,7 @@ app.MapPost("/v1/alerts/{alertId}/bundle/verify", async Task<Results<Ok<BundleVe
[FromBody] BundleVerificationRequest request,
[FromServices] IAlertService alertService,
[FromServices] IEvidenceBundleService bundleService,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
var alert = await alertService.GetAlertAsync(alertId, cancellationToken).ConfigureAwait(false);
@@ -1786,7 +1787,7 @@ app.MapPost("/v1/alerts/{alertId}/bundle/verify", async Task<Results<Ok<BundleVe
{
AlertId = alertId,
IsValid = result.IsValid,
VerifiedAt = DateTimeOffset.UtcNow,
VerifiedAt = timeProvider.GetUtcNow(),
SignatureValid = result.SignatureValid,
HashValid = result.HashValid,
ChainValid = result.ChainValid,

View File

@@ -11,13 +11,16 @@ public sealed class EvidenceGraphBuilder : IEvidenceGraphBuilder
{
private readonly IEvidenceRepository _evidenceRepo;
private readonly IAttestationVerifier _attestationVerifier;
private readonly TimeProvider _timeProvider;
public EvidenceGraphBuilder(
IEvidenceRepository evidenceRepo,
IAttestationVerifier attestationVerifier)
IAttestationVerifier attestationVerifier,
TimeProvider? timeProvider = null)
{
_evidenceRepo = evidenceRepo;
_attestationVerifier = attestationVerifier;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<EvidenceGraphResponse?> BuildAsync(
@@ -126,7 +129,7 @@ public sealed class EvidenceGraphBuilder : IEvidenceGraphBuilder
Nodes = nodes,
Edges = edges,
RootNodeId = verdictNode.Id,
GeneratedAt = DateTimeOffset.UtcNow
GeneratedAt = _timeProvider.GetUtcNow()
};
}

View File

@@ -66,7 +66,7 @@ public sealed class FindingWorkflowService : IFindingWorkflowService
var payload = CreateBasePayload(request);
payload["action"] = "assign";
payload["assignee"] = BuildAssigneeNode(request.Assignee);
payload["assignee"] = BuildAssigneeNode(request.Assignee!);
AddComment(payload, request.Comment);
ApplyStatus(payload, request.Status);
ApplyAttachments(payload, request.Attachments);

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Persistence/StellaOps.Notify.Persistence.csproj" />
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Queue/StellaOps.Notify.Queue.csproj" />
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Engine/StellaOps.Notify.Engine.csproj" />
<ProjectReference Include="../../../AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
</ItemGroup>
</Project>

View File

@@ -9,6 +9,12 @@ namespace StellaOps.Notify.Storage.InMemory.Repositories;
public sealed class NotifyChannelRepositoryAdapter : INotifyChannelRepository
{
private readonly ConcurrentDictionary<string, NotifyChannelDocument> _channels = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider _timeProvider;
public NotifyChannelRepositoryAdapter(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<NotifyChannelDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
@@ -34,7 +40,7 @@ public sealed class NotifyChannelRepositoryAdapter : INotifyChannelRepository
public Task<NotifyChannelDocument> UpsertAsync(NotifyChannelDocument channel, CancellationToken cancellationToken = default)
{
channel.UpdatedAt = DateTimeOffset.UtcNow;
channel.UpdatedAt = _timeProvider.GetUtcNow();
var key = $"{channel.TenantId}:{channel.Id}";
_channels[key] = channel;
return Task.FromResult(channel);
@@ -59,6 +65,12 @@ public sealed class NotifyChannelRepositoryAdapter : INotifyChannelRepository
public sealed class NotifyRuleRepositoryAdapter : INotifyRuleRepository
{
private readonly ConcurrentDictionary<string, NotifyRuleDocument> _rules = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider _timeProvider;
public NotifyRuleRepositoryAdapter(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<NotifyRuleDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
@@ -83,7 +95,7 @@ public sealed class NotifyRuleRepositoryAdapter : INotifyRuleRepository
public Task<NotifyRuleDocument> UpsertAsync(NotifyRuleDocument rule, CancellationToken cancellationToken = default)
{
rule.UpdatedAt = DateTimeOffset.UtcNow;
rule.UpdatedAt = _timeProvider.GetUtcNow();
var key = $"{rule.TenantId}:{rule.Id}";
_rules[key] = rule;
return Task.FromResult(rule);
@@ -108,6 +120,12 @@ public sealed class NotifyRuleRepositoryAdapter : INotifyRuleRepository
public sealed class NotifyTemplateRepositoryAdapter : INotifyTemplateRepository
{
private readonly ConcurrentDictionary<string, NotifyTemplateDocument> _templates = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider _timeProvider;
public NotifyTemplateRepositoryAdapter(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<NotifyTemplateDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
@@ -130,7 +148,7 @@ public sealed class NotifyTemplateRepositoryAdapter : INotifyTemplateRepository
public Task<NotifyTemplateDocument> UpsertAsync(NotifyTemplateDocument template, CancellationToken cancellationToken = default)
{
template.UpdatedAt = DateTimeOffset.UtcNow;
template.UpdatedAt = _timeProvider.GetUtcNow();
var key = $"{template.TenantId}:{template.Id}";
_templates[key] = template;
return Task.FromResult(template);
@@ -149,6 +167,12 @@ public sealed class NotifyTemplateRepositoryAdapter : INotifyTemplateRepository
public sealed class NotifyDeliveryRepositoryAdapter : INotifyDeliveryRepository
{
private readonly ConcurrentDictionary<string, NotifyDeliveryDocument> _deliveries = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider _timeProvider;
public NotifyDeliveryRepositoryAdapter(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<NotifyDeliveryDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
@@ -166,7 +190,7 @@ public sealed class NotifyDeliveryRepositoryAdapter : INotifyDeliveryRepository
public Task<NotifyDeliveryDocument> UpsertAsync(NotifyDeliveryDocument delivery, CancellationToken cancellationToken = default)
{
delivery.UpdatedAt = DateTimeOffset.UtcNow;
delivery.UpdatedAt = _timeProvider.GetUtcNow();
var key = $"{delivery.TenantId}:{delivery.Id}";
_deliveries[key] = delivery;
return Task.FromResult(delivery);
@@ -179,7 +203,7 @@ public sealed class NotifyDeliveryRepositoryAdapter : INotifyDeliveryRepository
{
doc.Status = status;
doc.Error = error;
doc.UpdatedAt = DateTimeOffset.UtcNow;
doc.UpdatedAt = _timeProvider.GetUtcNow();
return Task.FromResult(true);
}
return Task.FromResult(false);
@@ -199,6 +223,12 @@ public sealed class NotifyDeliveryRepositoryAdapter : INotifyDeliveryRepository
public sealed class NotifyDigestRepositoryAdapter : INotifyDigestRepository
{
private readonly ConcurrentDictionary<string, NotifyDigestDocument> _digests = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider _timeProvider;
public NotifyDigestRepositoryAdapter(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<NotifyDigestDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
@@ -209,7 +239,7 @@ public sealed class NotifyDigestRepositoryAdapter : INotifyDigestRepository
public Task<NotifyDigestDocument> UpsertAsync(NotifyDigestDocument digest, CancellationToken cancellationToken = default)
{
digest.UpdatedAt = DateTimeOffset.UtcNow;
digest.UpdatedAt = _timeProvider.GetUtcNow();
var key = $"{digest.TenantId}:{digest.Id}";
_digests[key] = digest;
return Task.FromResult(digest);
@@ -257,10 +287,16 @@ public sealed class NotifyAuditRepositoryAdapter : INotifyAuditRepository
public sealed class NotifyLockRepositoryAdapter : INotifyLockRepository
{
private readonly ConcurrentDictionary<string, (string Owner, DateTimeOffset ExpiresAt)> _locks = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider _timeProvider;
public NotifyLockRepositoryAdapter(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<bool> TryAcquireAsync(string lockKey, string owner, TimeSpan ttl, CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
// Clean up expired locks
foreach (var key in _locks.Keys.ToList())
@@ -288,7 +324,7 @@ public sealed class NotifyLockRepositoryAdapter : INotifyLockRepository
{
if (_locks.TryGetValue(lockKey, out var value) && value.Owner == owner)
{
var newExpiry = DateTimeOffset.UtcNow + ttl;
var newExpiry = _timeProvider.GetUtcNow() + ttl;
_locks[lockKey] = (owner, newExpiry);
return Task.FromResult(true);
}
@@ -302,6 +338,12 @@ public sealed class NotifyLockRepositoryAdapter : INotifyLockRepository
public sealed class NotifyEscalationPolicyRepositoryAdapter : INotifyEscalationPolicyRepository
{
private readonly ConcurrentDictionary<string, NotifyEscalationPolicyDocument> _policies = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider _timeProvider;
public NotifyEscalationPolicyRepositoryAdapter(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<NotifyEscalationPolicyDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
@@ -318,7 +360,7 @@ public sealed class NotifyEscalationPolicyRepositoryAdapter : INotifyEscalationP
public Task<NotifyEscalationPolicyDocument> UpsertAsync(NotifyEscalationPolicyDocument policy, CancellationToken cancellationToken = default)
{
policy.UpdatedAt = DateTimeOffset.UtcNow;
policy.UpdatedAt = _timeProvider.GetUtcNow();
var key = $"{policy.TenantId}:{policy.Id}";
_policies[key] = policy;
return Task.FromResult(policy);
@@ -331,6 +373,12 @@ public sealed class NotifyEscalationPolicyRepositoryAdapter : INotifyEscalationP
public sealed class NotifyEscalationStateRepositoryAdapter : INotifyEscalationStateRepository
{
private readonly ConcurrentDictionary<string, NotifyEscalationStateDocument> _states = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider _timeProvider;
public NotifyEscalationStateRepositoryAdapter(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<NotifyEscalationStateDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
@@ -341,7 +389,7 @@ public sealed class NotifyEscalationStateRepositoryAdapter : INotifyEscalationSt
public Task<NotifyEscalationStateDocument> UpsertAsync(NotifyEscalationStateDocument state, CancellationToken cancellationToken = default)
{
state.UpdatedAt = DateTimeOffset.UtcNow;
state.UpdatedAt = _timeProvider.GetUtcNow();
var key = $"{state.TenantId}:{state.Id}";
_states[key] = state;
return Task.FromResult(state);
@@ -360,6 +408,12 @@ public sealed class NotifyEscalationStateRepositoryAdapter : INotifyEscalationSt
public sealed class NotifyOnCallScheduleRepositoryAdapter : INotifyOnCallScheduleRepository
{
private readonly ConcurrentDictionary<string, NotifyOnCallScheduleDocument> _schedules = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider _timeProvider;
public NotifyOnCallScheduleRepositoryAdapter(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<NotifyOnCallScheduleDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
@@ -376,7 +430,7 @@ public sealed class NotifyOnCallScheduleRepositoryAdapter : INotifyOnCallSchedul
public Task<NotifyOnCallScheduleDocument> UpsertAsync(NotifyOnCallScheduleDocument schedule, CancellationToken cancellationToken = default)
{
schedule.UpdatedAt = DateTimeOffset.UtcNow;
schedule.UpdatedAt = _timeProvider.GetUtcNow();
var key = $"{schedule.TenantId}:{schedule.Id}";
_schedules[key] = schedule;
return Task.FromResult(schedule);
@@ -397,6 +451,12 @@ public sealed class NotifyOnCallScheduleRepositoryAdapter : INotifyOnCallSchedul
public sealed class NotifyQuietHoursRepositoryAdapter : INotifyQuietHoursRepository
{
private readonly ConcurrentDictionary<string, NotifyQuietHoursDocument> _quietHours = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider _timeProvider;
public NotifyQuietHoursRepositoryAdapter(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<NotifyQuietHoursDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
@@ -413,7 +473,7 @@ public sealed class NotifyQuietHoursRepositoryAdapter : INotifyQuietHoursReposit
public Task<NotifyQuietHoursDocument> UpsertAsync(NotifyQuietHoursDocument quietHours, CancellationToken cancellationToken = default)
{
quietHours.UpdatedAt = DateTimeOffset.UtcNow;
quietHours.UpdatedAt = _timeProvider.GetUtcNow();
var key = $"{quietHours.TenantId}:{quietHours.Id}";
_quietHours[key] = quietHours;
return Task.FromResult(quietHours);
@@ -432,6 +492,12 @@ public sealed class NotifyQuietHoursRepositoryAdapter : INotifyQuietHoursReposit
public sealed class NotifyMaintenanceWindowRepositoryAdapter : INotifyMaintenanceWindowRepository
{
private readonly ConcurrentDictionary<string, NotifyMaintenanceWindowDocument> _windows = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider _timeProvider;
public NotifyMaintenanceWindowRepositoryAdapter(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<NotifyMaintenanceWindowDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
@@ -454,7 +520,7 @@ public sealed class NotifyMaintenanceWindowRepositoryAdapter : INotifyMaintenanc
public Task<NotifyMaintenanceWindowDocument> UpsertAsync(NotifyMaintenanceWindowDocument window, CancellationToken cancellationToken = default)
{
window.UpdatedAt = DateTimeOffset.UtcNow;
window.UpdatedAt = _timeProvider.GetUtcNow();
var key = $"{window.TenantId}:{window.Id}";
_windows[key] = window;
return Task.FromResult(window);
@@ -473,6 +539,12 @@ public sealed class NotifyMaintenanceWindowRepositoryAdapter : INotifyMaintenanc
public sealed class NotifyInboxRepositoryAdapter : INotifyInboxRepository
{
private readonly ConcurrentDictionary<string, NotifyInboxDocument> _inbox = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider _timeProvider;
public NotifyInboxRepositoryAdapter(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<NotifyInboxDocument?> GetByIdAsync(string tenantId, string id, CancellationToken cancellationToken = default)
{
@@ -502,7 +574,7 @@ public sealed class NotifyInboxRepositoryAdapter : INotifyInboxRepository
if (_inbox.TryGetValue(key, out var doc))
{
doc.Read = true;
doc.ReadAt = DateTimeOffset.UtcNow;
doc.ReadAt = _timeProvider.GetUtcNow();
return Task.FromResult(true);
}
return Task.FromResult(false);

View File

@@ -16,6 +16,7 @@ public sealed class LedgerExporter : ILedgerExporter
private readonly ILedgerRepository _ledgerRepository;
private readonly ILedgerExportRepository _exportRepository;
private readonly ILogger<LedgerExporter> _logger;
private readonly TimeProvider _timeProvider;
private static readonly JsonSerializerOptions JsonOptions = new()
{
@@ -32,11 +33,13 @@ public sealed class LedgerExporter : ILedgerExporter
public LedgerExporter(
ILedgerRepository ledgerRepository,
ILedgerExportRepository exportRepository,
ILogger<LedgerExporter> logger)
ILogger<LedgerExporter> logger,
TimeProvider? timeProvider = null)
{
_ledgerRepository = ledgerRepository;
_exportRepository = exportRepository;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
@@ -44,7 +47,7 @@ public sealed class LedgerExporter : ILedgerExporter
LedgerExport export,
CancellationToken cancellationToken = default)
{
var startTime = DateTimeOffset.UtcNow;
var startTime = _timeProvider.GetUtcNow();
try
{
@@ -83,7 +86,7 @@ public sealed class LedgerExporter : ILedgerExporter
export = export.Complete(outputUri, digest, sizeBytes, entries.Count);
export = await _exportRepository.UpdateAsync(export, cancellationToken);
var duration = DateTimeOffset.UtcNow - startTime;
var duration = _timeProvider.GetUtcNow() - startTime;
OrchestratorMetrics.LedgerExportCompleted(export.TenantId, export.Format);
OrchestratorMetrics.RecordLedgerExportDuration(export.TenantId, export.Format, duration.TotalSeconds);
OrchestratorMetrics.RecordLedgerExportSize(export.TenantId, export.Format, sizeBytes);
@@ -165,12 +168,12 @@ public sealed class LedgerExporter : ILedgerExporter
return (content, digest);
}
private static string GenerateJson(IReadOnlyList<RunLedgerEntry> entries)
private string GenerateJson(IReadOnlyList<RunLedgerEntry> entries)
{
var exportData = new LedgerExportData
{
SchemaVersion = "1.0.0",
ExportedAt = DateTimeOffset.UtcNow,
ExportedAt = _timeProvider.GetUtcNow(),
EntryCount = entries.Count,
Entries = entries.Select(MapEntry).ToList()
};

View File

@@ -56,15 +56,18 @@ public sealed class PostgresDuplicateSuppressor : IDuplicateSuppressor
private readonly OrchestratorDataSource _dataSource;
private readonly string _tenantId;
private readonly ILogger<PostgresDuplicateSuppressor> _logger;
private readonly TimeProvider _timeProvider;
public PostgresDuplicateSuppressor(
OrchestratorDataSource dataSource,
string tenantId,
ILogger<PostgresDuplicateSuppressor> logger)
ILogger<PostgresDuplicateSuppressor> logger,
TimeProvider? timeProvider = null)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<bool> HasProcessedAsync(string scopeKey, string eventKey, CancellationToken cancellationToken)
@@ -125,7 +128,7 @@ public sealed class PostgresDuplicateSuppressor : IDuplicateSuppressor
command.Parameters.AddWithValue("event_key", eventKey);
command.Parameters.AddWithValue("event_time", eventTime);
command.Parameters.AddWithValue("batch_id", (object?)batchId ?? DBNull.Value);
command.Parameters.AddWithValue("expires_at", DateTimeOffset.UtcNow + ttl);
command.Parameters.AddWithValue("expires_at", _timeProvider.GetUtcNow() + ttl);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
@@ -143,7 +146,7 @@ public sealed class PostgresDuplicateSuppressor : IDuplicateSuppressor
return;
}
var expiresAt = DateTimeOffset.UtcNow + ttl;
var expiresAt = _timeProvider.GetUtcNow() + ttl;
await using var connection = await _dataSource.OpenConnectionAsync(_tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);

View File

@@ -108,13 +108,16 @@ public sealed class PostgresJobRepository : IJobRepository
private readonly OrchestratorDataSource _dataSource;
private readonly ILogger<PostgresJobRepository> _logger;
private readonly TimeProvider _timeProvider;
public PostgresJobRepository(
OrchestratorDataSource dataSource,
ILogger<PostgresJobRepository> logger)
ILogger<PostgresJobRepository> logger,
TimeProvider? timeProvider = null)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<Job?> GetByIdAsync(string tenantId, Guid jobId, CancellationToken cancellationToken)
@@ -228,8 +231,8 @@ public sealed class PostgresJobRepository : IJobRepository
command.Parameters.AddWithValue("lease_id", leaseId);
command.Parameters.AddWithValue("worker_id", workerId);
command.Parameters.AddWithValue("lease_until", leaseUntil);
command.Parameters.AddWithValue("leased_at", DateTimeOffset.UtcNow);
command.Parameters.AddWithValue("now", DateTimeOffset.UtcNow);
command.Parameters.AddWithValue("leased_at", _timeProvider.GetUtcNow());
command.Parameters.AddWithValue("now", _timeProvider.GetUtcNow());
if (jobType != null)
{
@@ -263,7 +266,7 @@ public sealed class PostgresJobRepository : IJobRepository
command.Parameters.AddWithValue("job_id", jobId);
command.Parameters.AddWithValue("lease_id", leaseId);
command.Parameters.AddWithValue("new_lease_until", newLeaseUntil);
command.Parameters.AddWithValue("now", DateTimeOffset.UtcNow);
command.Parameters.AddWithValue("now", _timeProvider.GetUtcNow());
var rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
return rows > 0;

View File

@@ -13,6 +13,7 @@ public sealed class PostgresPackRegistryRepository : IPackRegistryRepository
{
private readonly OrchestratorDataSource _dataSource;
private readonly ILogger<PostgresPackRegistryRepository> _logger;
private readonly TimeProvider _timeProvider;
private const string PackColumns = """
pack_id, tenant_id, project_id, name, display_name, description,
@@ -33,10 +34,12 @@ public sealed class PostgresPackRegistryRepository : IPackRegistryRepository
public PostgresPackRegistryRepository(
OrchestratorDataSource dataSource,
ILogger<PostgresPackRegistryRepository> logger)
ILogger<PostgresPackRegistryRepository> logger,
TimeProvider? timeProvider = null)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
// Pack CRUD
@@ -264,7 +267,7 @@ public sealed class PostgresPackRegistryRepository : IPackRegistryRepository
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("pack_id", packId);
command.Parameters.AddWithValue("status", status.ToString().ToLowerInvariant());
command.Parameters.AddWithValue("updated_at", DateTimeOffset.UtcNow.UtcDateTime);
command.Parameters.AddWithValue("updated_at", _timeProvider.GetUtcNow().UtcDateTime);
command.Parameters.AddWithValue("updated_by", updatedBy);
command.Parameters.AddWithValue("published_at", (object?)publishedAt?.UtcDateTime ?? DBNull.Value);
command.Parameters.AddWithValue("published_by", (object?)publishedBy ?? DBNull.Value);
@@ -534,7 +537,7 @@ public sealed class PostgresPackRegistryRepository : IPackRegistryRepository
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("pack_version_id", packVersionId);
command.Parameters.AddWithValue("status", status.ToString().ToLowerInvariant());
command.Parameters.AddWithValue("updated_at", DateTimeOffset.UtcNow.UtcDateTime);
command.Parameters.AddWithValue("updated_at", _timeProvider.GetUtcNow().UtcDateTime);
command.Parameters.AddWithValue("updated_by", updatedBy);
command.Parameters.AddWithValue("published_at", (object?)publishedAt?.UtcDateTime ?? DBNull.Value);
command.Parameters.AddWithValue("published_by", (object?)publishedBy ?? DBNull.Value);
@@ -574,7 +577,7 @@ public sealed class PostgresPackRegistryRepository : IPackRegistryRepository
command.Parameters.AddWithValue("signature_algorithm", signatureAlgorithm);
command.Parameters.AddWithValue("signed_by", signedBy);
command.Parameters.AddWithValue("signed_at", signedAt.UtcDateTime);
command.Parameters.AddWithValue("updated_at", DateTimeOffset.UtcNow.UtcDateTime);
command.Parameters.AddWithValue("updated_at", _timeProvider.GetUtcNow().UtcDateTime);
await command.ExecuteNonQueryAsync(cancellationToken);
}

View File

@@ -128,11 +128,16 @@ public sealed class PostgresPackRunRepository : IPackRunRepository
private readonly OrchestratorDataSource _dataSource;
private readonly ILogger<PostgresPackRunRepository> _logger;
private readonly TimeProvider _timeProvider;
public PostgresPackRunRepository(OrchestratorDataSource dataSource, ILogger<PostgresPackRunRepository> logger)
public PostgresPackRunRepository(
OrchestratorDataSource dataSource,
ILogger<PostgresPackRunRepository> logger,
TimeProvider? timeProvider = null)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<PackRun?> GetByIdAsync(string tenantId, Guid packRunId, CancellationToken cancellationToken)
@@ -244,7 +249,7 @@ public sealed class PostgresPackRunRepository : IPackRunRepository
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("lease_id", leaseId);
command.Parameters.AddWithValue("task_runner_id", taskRunnerId);
@@ -275,7 +280,7 @@ public sealed class PostgresPackRunRepository : IPackRunRepository
command.Parameters.AddWithValue("pack_run_id", packRunId);
command.Parameters.AddWithValue("lease_id", leaseId);
command.Parameters.AddWithValue("new_lease_until", newLeaseUntil);
command.Parameters.AddWithValue("now", DateTimeOffset.UtcNow);
command.Parameters.AddWithValue("now", _timeProvider.GetUtcNow());
var rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
return rows > 0;
@@ -292,7 +297,7 @@ public sealed class PostgresPackRunRepository : IPackRunRepository
command.Parameters.AddWithValue("lease_id", leaseId);
command.Parameters.AddWithValue("status", StatusToString(newStatus));
command.Parameters.AddWithValue("reason", (object?)reason ?? DBNull.Value);
command.Parameters.AddWithValue("completed_at", DateTimeOffset.UtcNow);
command.Parameters.AddWithValue("completed_at", _timeProvider.GetUtcNow());
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}

View File

@@ -113,13 +113,16 @@ public sealed class PostgresQuotaRepository : IQuotaRepository
private readonly OrchestratorDataSource _dataSource;
private readonly ILogger<PostgresQuotaRepository> _logger;
private readonly TimeProvider _timeProvider;
public PostgresQuotaRepository(
OrchestratorDataSource dataSource,
ILogger<PostgresQuotaRepository> logger)
ILogger<PostgresQuotaRepository> logger,
TimeProvider? timeProvider = null)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<Quota?> GetByIdAsync(string tenantId, Guid quotaId, CancellationToken cancellationToken)
@@ -229,7 +232,7 @@ public sealed class PostgresQuotaRepository : IQuotaRepository
command.Parameters.AddWithValue("current_active", currentActive);
command.Parameters.AddWithValue("current_hour_count", currentHourCount);
command.Parameters.AddWithValue("current_hour_start", currentHourStart);
command.Parameters.AddWithValue("updated_at", DateTimeOffset.UtcNow);
command.Parameters.AddWithValue("updated_at", _timeProvider.GetUtcNow());
command.Parameters.AddWithValue("updated_by", updatedBy);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
@@ -245,7 +248,7 @@ public sealed class PostgresQuotaRepository : IQuotaRepository
command.Parameters.AddWithValue("quota_id", quotaId);
command.Parameters.AddWithValue("pause_reason", reason);
command.Parameters.AddWithValue("quota_ticket", (object?)ticket ?? DBNull.Value);
command.Parameters.AddWithValue("updated_at", DateTimeOffset.UtcNow);
command.Parameters.AddWithValue("updated_at", _timeProvider.GetUtcNow());
command.Parameters.AddWithValue("updated_by", updatedBy);
var rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
@@ -263,7 +266,7 @@ public sealed class PostgresQuotaRepository : IQuotaRepository
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("quota_id", quotaId);
command.Parameters.AddWithValue("updated_at", DateTimeOffset.UtcNow);
command.Parameters.AddWithValue("updated_at", _timeProvider.GetUtcNow());
command.Parameters.AddWithValue("updated_by", updatedBy);
var rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
@@ -281,7 +284,7 @@ public sealed class PostgresQuotaRepository : IQuotaRepository
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("quota_id", quotaId);
command.Parameters.AddWithValue("updated_at", DateTimeOffset.UtcNow);
command.Parameters.AddWithValue("updated_at", _timeProvider.GetUtcNow());
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
@@ -294,7 +297,7 @@ public sealed class PostgresQuotaRepository : IQuotaRepository
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("quota_id", quotaId);
command.Parameters.AddWithValue("updated_at", DateTimeOffset.UtcNow);
command.Parameters.AddWithValue("updated_at", _timeProvider.GetUtcNow());
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}

View File

@@ -69,13 +69,16 @@ public sealed class PostgresRunRepository : IRunRepository
private readonly OrchestratorDataSource _dataSource;
private readonly ILogger<PostgresRunRepository> _logger;
private readonly TimeProvider _timeProvider;
public PostgresRunRepository(
OrchestratorDataSource dataSource,
ILogger<PostgresRunRepository> logger)
ILogger<PostgresRunRepository> logger,
TimeProvider? timeProvider = null)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<Run?> GetByIdAsync(string tenantId, Guid runId, CancellationToken cancellationToken)
@@ -149,7 +152,7 @@ public sealed class PostgresRunRepository : IRunRepository
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("run_id", runId);
command.Parameters.AddWithValue("succeeded", succeeded);
command.Parameters.AddWithValue("now", DateTimeOffset.UtcNow);
command.Parameters.AddWithValue("now", _timeProvider.GetUtcNow());
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))

View File

@@ -74,13 +74,16 @@ public sealed class PostgresSourceRepository : ISourceRepository
private readonly OrchestratorDataSource _dataSource;
private readonly ILogger<PostgresSourceRepository> _logger;
private readonly TimeProvider _timeProvider;
public PostgresSourceRepository(
OrchestratorDataSource dataSource,
ILogger<PostgresSourceRepository> logger)
ILogger<PostgresSourceRepository> logger,
TimeProvider? timeProvider = null)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<Source?> GetByIdAsync(string tenantId, Guid sourceId, CancellationToken cancellationToken)
@@ -175,7 +178,7 @@ public sealed class PostgresSourceRepository : ISourceRepository
command.Parameters.AddWithValue("source_id", sourceId);
command.Parameters.AddWithValue("pause_reason", reason);
command.Parameters.AddWithValue("pause_ticket", (object?)ticket ?? DBNull.Value);
command.Parameters.AddWithValue("updated_at", DateTimeOffset.UtcNow);
command.Parameters.AddWithValue("updated_at", _timeProvider.GetUtcNow());
command.Parameters.AddWithValue("updated_by", updatedBy);
var rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
@@ -193,7 +196,7 @@ public sealed class PostgresSourceRepository : ISourceRepository
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("source_id", sourceId);
command.Parameters.AddWithValue("updated_at", DateTimeOffset.UtcNow);
command.Parameters.AddWithValue("updated_at", _timeProvider.GetUtcNow());
command.Parameters.AddWithValue("updated_by", updatedBy);
var rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);

View File

@@ -77,13 +77,16 @@ public sealed class PostgresThrottleRepository : IThrottleRepository
private readonly OrchestratorDataSource _dataSource;
private readonly ILogger<PostgresThrottleRepository> _logger;
private readonly TimeProvider _timeProvider;
public PostgresThrottleRepository(
OrchestratorDataSource dataSource,
ILogger<PostgresThrottleRepository> logger)
ILogger<PostgresThrottleRepository> logger,
TimeProvider? timeProvider = null)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<Throttle?> GetByIdAsync(string tenantId, Guid throttleId, CancellationToken cancellationToken)
@@ -110,7 +113,7 @@ public sealed class PostgresThrottleRepository : IThrottleRepository
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("source_id", sourceId);
command.Parameters.AddWithValue("now", DateTimeOffset.UtcNow);
command.Parameters.AddWithValue("now", _timeProvider.GetUtcNow());
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var throttles = new List<Throttle>();
@@ -128,7 +131,7 @@ public sealed class PostgresThrottleRepository : IThrottleRepository
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("job_type", jobType);
command.Parameters.AddWithValue("now", DateTimeOffset.UtcNow);
command.Parameters.AddWithValue("now", _timeProvider.GetUtcNow());
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var throttles = new List<Throttle>();

View File

@@ -100,13 +100,16 @@ public sealed class PostgresWatermarkRepository : IWatermarkRepository
private readonly OrchestratorDataSource _dataSource;
private readonly ILogger<PostgresWatermarkRepository> _logger;
private readonly TimeProvider _timeProvider;
public PostgresWatermarkRepository(
OrchestratorDataSource dataSource,
ILogger<PostgresWatermarkRepository> logger)
ILogger<PostgresWatermarkRepository> logger,
TimeProvider? timeProvider = null)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<Watermark?> GetByScopeKeyAsync(string tenantId, string scopeKey, CancellationToken cancellationToken)
@@ -271,7 +274,7 @@ public sealed class PostgresWatermarkRepository : IWatermarkRepository
int limit,
CancellationToken cancellationToken)
{
var thresholdTime = DateTimeOffset.UtcNow - lagThreshold;
var thresholdTime = _timeProvider.GetUtcNow() - lagThreshold;
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(SelectLaggingSql, connection);

View File

@@ -152,7 +152,8 @@ public sealed record BackfillCheckpoint(
int batchNumber,
DateTimeOffset batchStart,
DateTimeOffset batchEnd,
int eventsInBatch)
int eventsInBatch,
DateTimeOffset startedAt)
{
return new BackfillCheckpoint(
CheckpointId: Guid.NewGuid(),
@@ -166,7 +167,7 @@ public sealed record BackfillCheckpoint(
EventsSkipped: 0,
EventsFailed: 0,
BatchHash: null,
StartedAt: DateTimeOffset.UtcNow,
StartedAt: startedAt,
CompletedAt: null,
ErrorMessage: null);
}
@@ -174,7 +175,7 @@ public sealed record BackfillCheckpoint(
/// <summary>
/// Marks the checkpoint as complete.
/// </summary>
public BackfillCheckpoint Complete(int processed, int skipped, int failed, string? batchHash)
public BackfillCheckpoint Complete(int processed, int skipped, int failed, string? batchHash, DateTimeOffset completedAt)
{
return this with
{
@@ -182,18 +183,18 @@ public sealed record BackfillCheckpoint(
EventsSkipped = skipped,
EventsFailed = failed,
BatchHash = batchHash,
CompletedAt = DateTimeOffset.UtcNow
CompletedAt = completedAt
};
}
/// <summary>
/// Marks the checkpoint as failed.
/// </summary>
public BackfillCheckpoint Fail(string error)
public BackfillCheckpoint Fail(string error, DateTimeOffset completedAt)
{
return this with
{
CompletedAt = DateTimeOffset.UtcNow,
CompletedAt = completedAt,
ErrorMessage = error
};
}

View File

@@ -14,15 +14,18 @@ public sealed class FirstSignalSnapshotWriter : BackgroundService
private readonly IServiceScopeFactory _scopeFactory;
private readonly FirstSignalSnapshotWriterOptions _options;
private readonly ILogger<FirstSignalSnapshotWriter> _logger;
private readonly TimeProvider _timeProvider;
public FirstSignalSnapshotWriter(
IServiceScopeFactory scopeFactory,
IOptions<FirstSignalOptions> options,
ILogger<FirstSignalSnapshotWriter> logger)
ILogger<FirstSignalSnapshotWriter> logger,
TimeProvider? timeProvider = null)
{
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value.SnapshotWriter;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -76,7 +79,7 @@ public sealed class FirstSignalSnapshotWriter : BackgroundService
var runRepository = scope.ServiceProvider.GetRequiredService<IRunRepository>();
var firstSignalService = scope.ServiceProvider.GetRequiredService<CoreServices.IFirstSignalService>();
var createdAfter = DateTimeOffset.UtcNow.Subtract(lookback);
var createdAfter = _timeProvider.GetUtcNow().Subtract(lookback);
var pending = await runRepository.ListAsync(
tenantId,

View File

@@ -36,13 +36,14 @@ public static class HealthEndpoints
return app;
}
private static IResult GetHealth()
private static IResult GetHealth([FromServices] TimeProvider timeProvider)
{
return Results.Ok(new HealthResponse("ok", DateTimeOffset.UtcNow));
return Results.Ok(new HealthResponse("ok", timeProvider.GetUtcNow()));
}
private static async Task<IResult> GetReadiness(
[FromServices] OrchestratorDataSource dataSource,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken)
{
try
@@ -53,14 +54,14 @@ public static class HealthEndpoints
if (!dbHealthy)
{
return Results.Json(
new ReadinessResponse("not_ready", DateTimeOffset.UtcNow, new Dictionary<string, string>
new ReadinessResponse("not_ready", timeProvider.GetUtcNow(), new Dictionary<string, string>
{
["database"] = "unhealthy"
}),
statusCode: StatusCodes.Status503ServiceUnavailable);
}
return Results.Ok(new ReadinessResponse("ready", DateTimeOffset.UtcNow, new Dictionary<string, string>
return Results.Ok(new ReadinessResponse("ready", timeProvider.GetUtcNow(), new Dictionary<string, string>
{
["database"] = "healthy"
}));
@@ -68,7 +69,7 @@ public static class HealthEndpoints
catch (Exception ex)
{
return Results.Json(
new ReadinessResponse("not_ready", DateTimeOffset.UtcNow, new Dictionary<string, string>
new ReadinessResponse("not_ready", timeProvider.GetUtcNow(), new Dictionary<string, string>
{
["database"] = $"error: {ex.Message}"
}),
@@ -76,14 +77,15 @@ public static class HealthEndpoints
}
}
private static IResult GetLiveness()
private static IResult GetLiveness([FromServices] TimeProvider timeProvider)
{
// Liveness just checks the process is alive
return Results.Ok(new HealthResponse("alive", DateTimeOffset.UtcNow));
return Results.Ok(new HealthResponse("alive", timeProvider.GetUtcNow()));
}
private static async Task<IResult> GetHealthDetails(
[FromServices] OrchestratorDataSource dataSource,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken)
{
var checks = new Dictionary<string, HealthCheckResult>();
@@ -96,12 +98,12 @@ public static class HealthEndpoints
checks["database"] = new HealthCheckResult(
dbHealthy ? "healthy" : "unhealthy",
dbHealthy ? null : "Connection test failed",
DateTimeOffset.UtcNow);
timeProvider.GetUtcNow());
overallHealthy &= dbHealthy;
}
catch (Exception ex)
{
checks["database"] = new HealthCheckResult("unhealthy", ex.Message, DateTimeOffset.UtcNow);
checks["database"] = new HealthCheckResult("unhealthy", ex.Message, timeProvider.GetUtcNow());
overallHealthy = false;
}
@@ -114,7 +116,7 @@ public static class HealthEndpoints
checks["memory"] = new HealthCheckResult(
memoryHealthy ? "healthy" : "degraded",
$"Used: {memoryUsedMb:F2} MB",
DateTimeOffset.UtcNow);
timeProvider.GetUtcNow());
// Thread pool check
ThreadPool.GetAvailableThreads(out var workerThreads, out var completionPortThreads);
@@ -124,11 +126,11 @@ public static class HealthEndpoints
checks["threadPool"] = new HealthCheckResult(
threadPoolHealthy ? "healthy" : "degraded",
$"Worker threads available: {workerThreads}/{maxWorkerThreads}",
DateTimeOffset.UtcNow);
timeProvider.GetUtcNow());
var response = new HealthDetailsResponse(
overallHealthy ? "healthy" : "unhealthy",
DateTimeOffset.UtcNow,
timeProvider.GetUtcNow(),
checks);
return overallHealthy

View File

@@ -55,10 +55,12 @@ public static class KpiEndpoints
[FromQuery] DateTimeOffset? to,
[FromQuery] string? tenant,
[FromServices] IKpiCollector collector,
[FromServices] TimeProvider timeProvider,
CancellationToken ct)
{
var start = from ?? DateTimeOffset.UtcNow.AddDays(-7);
var end = to ?? DateTimeOffset.UtcNow;
var now = timeProvider.GetUtcNow();
var start = from ?? now.AddDays(-7);
var end = to ?? now;
var kpis = await collector.CollectAsync(start, end, tenant, ct);
return Results.Ok(kpis);
@@ -69,11 +71,13 @@ public static class KpiEndpoints
[FromQuery] DateTimeOffset? to,
[FromQuery] string? tenant,
[FromServices] IKpiCollector collector,
[FromServices] TimeProvider timeProvider,
CancellationToken ct)
{
var now = timeProvider.GetUtcNow();
var kpis = await collector.CollectAsync(
from ?? DateTimeOffset.UtcNow.AddDays(-7),
to ?? DateTimeOffset.UtcNow,
from ?? now.AddDays(-7),
to ?? now,
tenant,
ct);
return Results.Ok(kpis.Reachability);
@@ -84,11 +88,13 @@ public static class KpiEndpoints
[FromQuery] DateTimeOffset? to,
[FromQuery] string? tenant,
[FromServices] IKpiCollector collector,
[FromServices] TimeProvider timeProvider,
CancellationToken ct)
{
var now = timeProvider.GetUtcNow();
var kpis = await collector.CollectAsync(
from ?? DateTimeOffset.UtcNow.AddDays(-7),
to ?? DateTimeOffset.UtcNow,
from ?? now.AddDays(-7),
to ?? now,
tenant,
ct);
return Results.Ok(kpis.Explainability);
@@ -99,11 +105,13 @@ public static class KpiEndpoints
[FromQuery] DateTimeOffset? to,
[FromQuery] string? tenant,
[FromServices] IKpiCollector collector,
[FromServices] TimeProvider timeProvider,
CancellationToken ct)
{
var now = timeProvider.GetUtcNow();
var kpis = await collector.CollectAsync(
from ?? DateTimeOffset.UtcNow.AddDays(-7),
to ?? DateTimeOffset.UtcNow,
from ?? now.AddDays(-7),
to ?? now,
tenant,
ct);
return Results.Ok(kpis.Runtime);
@@ -114,11 +122,13 @@ public static class KpiEndpoints
[FromQuery] DateTimeOffset? to,
[FromQuery] string? tenant,
[FromServices] IKpiCollector collector,
[FromServices] TimeProvider timeProvider,
CancellationToken ct)
{
var now = timeProvider.GetUtcNow();
var kpis = await collector.CollectAsync(
from ?? DateTimeOffset.UtcNow.AddDays(-7),
to ?? DateTimeOffset.UtcNow,
from ?? now.AddDays(-7),
to ?? now,
tenant,
ct);
return Results.Ok(kpis.Replay);

View File

@@ -375,8 +375,8 @@ app.MapConflictsApi();
app.Run();
// Make Program class partial to allow integration testing while keeping it minimal
// Make Program class internal to prevent type conflicts when referencing this assembly
namespace StellaOps.Policy.Engine
{
public partial class Program { }
internal partial class Program { }
}

View File

@@ -6,12 +6,18 @@ namespace StellaOps.Policy.Engine.Services;
internal sealed class InMemoryPolicyPackRepository : IPolicyPackRepository
{
private readonly ConcurrentDictionary<string, PolicyPackRecord> packs = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider _timeProvider;
public InMemoryPolicyPackRepository(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<PolicyPackRecord> CreateAsync(string packId, string? displayName, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(packId);
var created = packs.GetOrAdd(packId, id => new PolicyPackRecord(id, displayName, DateTimeOffset.UtcNow));
var created = packs.GetOrAdd(packId, id => new PolicyPackRecord(id, displayName, _timeProvider.GetUtcNow()));
return Task.FromResult(created);
}
@@ -25,15 +31,15 @@ internal sealed class InMemoryPolicyPackRepository : IPolicyPackRepository
public Task<PolicyRevisionRecord> UpsertRevisionAsync(string packId, int version, bool requiresTwoPersonApproval, PolicyRevisionStatus initialStatus, CancellationToken cancellationToken)
{
var pack = packs.GetOrAdd(packId, id => new PolicyPackRecord(id, null, DateTimeOffset.UtcNow));
var pack = packs.GetOrAdd(packId, id => new PolicyPackRecord(id, null, _timeProvider.GetUtcNow()));
int revisionVersion = version > 0 ? version : pack.GetNextVersion();
var revision = pack.GetOrAddRevision(
revisionVersion,
v => new PolicyRevisionRecord(v, requiresTwoPersonApproval, initialStatus, DateTimeOffset.UtcNow));
v => new PolicyRevisionRecord(v, requiresTwoPersonApproval, initialStatus, _timeProvider.GetUtcNow()));
if (revision.Status != initialStatus)
{
revision.SetStatus(initialStatus, DateTimeOffset.UtcNow);
revision.SetStatus(initialStatus, _timeProvider.GetUtcNow());
}
return Task.FromResult(revision);
@@ -95,9 +101,9 @@ internal sealed class InMemoryPolicyPackRepository : IPolicyPackRepository
{
ArgumentNullException.ThrowIfNull(bundle);
var pack = packs.GetOrAdd(packId, id => new PolicyPackRecord(id, null, DateTimeOffset.UtcNow));
var pack = packs.GetOrAdd(packId, id => new PolicyPackRecord(id, null, _timeProvider.GetUtcNow()));
var revision = pack.GetOrAddRevision(version > 0 ? version : pack.GetNextVersion(),
v => new PolicyRevisionRecord(v, requiresTwoPerson: false, status: PolicyRevisionStatus.Draft, DateTimeOffset.UtcNow));
v => new PolicyRevisionRecord(v, requiresTwoPerson: false, status: PolicyRevisionStatus.Draft, _timeProvider.GetUtcNow()));
revision.SetBundle(bundle);
return Task.FromResult(bundle);

View File

@@ -13,6 +13,12 @@ namespace StellaOps.Policy.Engine.Storage.InMemory;
public sealed class InMemoryExceptionRepository : IExceptionRepository
{
private readonly ConcurrentDictionary<(string Tenant, Guid Id), ExceptionEntity> _exceptions = new();
private readonly TimeProvider _timeProvider;
public InMemoryExceptionRepository(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<ExceptionEntity> CreateAsync(ExceptionEntity exception, CancellationToken cancellationToken = default)
{
@@ -123,7 +129,7 @@ public sealed class InMemoryExceptionRepository : IExceptionRepository
_exceptions[key] = Copy(
existing,
statusOverride: ExceptionStatus.Revoked,
revokedAtOverride: DateTimeOffset.UtcNow,
revokedAtOverride: _timeProvider.GetUtcNow(),
revokedByOverride: revokedBy);
return Task.FromResult(true);
}
@@ -133,7 +139,7 @@ public sealed class InMemoryExceptionRepository : IExceptionRepository
public Task<int> ExpireAsync(string tenantId, CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
var normalizedTenant = Normalize(tenantId);
var expired = 0;

View File

@@ -89,6 +89,7 @@ public static class ExceptionApprovalEndpoints
CreateApprovalRequestDto request,
IExceptionApprovalRepository repository,
IExceptionApprovalRulesService rulesService,
[FromServices] TimeProvider timeProvider,
ILogger<ExceptionApprovalRequestEntity> logger,
CancellationToken cancellationToken)
{
@@ -110,7 +111,7 @@ public static class ExceptionApprovalEndpoints
}
// Generate request ID
var requestId = $"EAR-{DateTimeOffset.UtcNow:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
var requestId = $"EAR-{timeProvider.GetUtcNow():yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
// Parse gate level
if (!Enum.TryParse<GateLevel>(request.GateLevel, ignoreCase: true, out var gateLevel))
@@ -139,7 +140,7 @@ public static class ExceptionApprovalEndpoints
});
}
var now = DateTimeOffset.UtcNow;
var now = timeProvider.GetUtcNow();
var entity = new ExceptionApprovalRequestEntity
{
Id = Guid.NewGuid(),

View File

@@ -134,6 +134,7 @@ public static class ExceptionEndpoints
CreateExceptionRequest request,
HttpContext context,
IExceptionRepository repository,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
if (request is null)
@@ -146,7 +147,7 @@ public static class ExceptionEndpoints
}
// Validate expiry is in future
if (request.ExpiresAt <= DateTimeOffset.UtcNow)
if (request.ExpiresAt <= timeProvider.GetUtcNow())
{
return Results.BadRequest(new ProblemDetails
{
@@ -157,7 +158,7 @@ public static class ExceptionEndpoints
}
// Validate expiry is not more than 1 year
if (request.ExpiresAt > DateTimeOffset.UtcNow.AddYears(1))
if (request.ExpiresAt > timeProvider.GetUtcNow().AddYears(1))
{
return Results.BadRequest(new ProblemDetails
{
@@ -188,8 +189,8 @@ public static class ExceptionEndpoints
},
OwnerId = request.OwnerId,
RequesterId = actorId,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
CreatedAt = timeProvider.GetUtcNow(),
UpdatedAt = timeProvider.GetUtcNow(),
ExpiresAt = request.ExpiresAt,
ReasonCode = ParseReasonRequired(request.ReasonCode),
Rationale = request.Rationale,
@@ -210,6 +211,7 @@ public static class ExceptionEndpoints
UpdateExceptionRequest request,
HttpContext context,
IExceptionRepository repository,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
var existing = await repository.GetByIdAsync(id, cancellationToken);
@@ -238,7 +240,7 @@ public static class ExceptionEndpoints
var updated = existing with
{
Version = existing.Version + 1,
UpdatedAt = DateTimeOffset.UtcNow,
UpdatedAt = timeProvider.GetUtcNow(),
Rationale = request.Rationale ?? existing.Rationale,
EvidenceRefs = request.EvidenceRefs?.ToImmutableArray() ?? existing.EvidenceRefs,
CompensatingControls = request.CompensatingControls?.ToImmutableArray() ?? existing.CompensatingControls,
@@ -258,6 +260,7 @@ public static class ExceptionEndpoints
ApproveExceptionRequest? request,
HttpContext context,
IExceptionRepository repository,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
var existing = await repository.GetByIdAsync(id, cancellationToken);
@@ -294,8 +297,8 @@ public static class ExceptionEndpoints
{
Version = existing.Version + 1,
Status = ExceptionStatus.Approved,
UpdatedAt = DateTimeOffset.UtcNow,
ApprovedAt = DateTimeOffset.UtcNow,
UpdatedAt = timeProvider.GetUtcNow(),
ApprovedAt = timeProvider.GetUtcNow(),
ApproverIds = existing.ApproverIds.Add(actorId)
};
@@ -310,6 +313,7 @@ public static class ExceptionEndpoints
string id,
HttpContext context,
IExceptionRepository repository,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
var existing = await repository.GetByIdAsync(id, cancellationToken);
@@ -335,7 +339,7 @@ public static class ExceptionEndpoints
{
Version = existing.Version + 1,
Status = ExceptionStatus.Active,
UpdatedAt = DateTimeOffset.UtcNow
UpdatedAt = timeProvider.GetUtcNow()
};
var result = await repository.UpdateAsync(
@@ -350,6 +354,7 @@ public static class ExceptionEndpoints
ExtendExceptionRequest request,
HttpContext context,
IExceptionRepository repository,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
var existing = await repository.GetByIdAsync(id, cancellationToken);
@@ -384,7 +389,7 @@ public static class ExceptionEndpoints
var updated = existing with
{
Version = existing.Version + 1,
UpdatedAt = DateTimeOffset.UtcNow,
UpdatedAt = timeProvider.GetUtcNow(),
ExpiresAt = request.NewExpiresAt
};
@@ -400,6 +405,7 @@ public static class ExceptionEndpoints
[FromBody] RevokeExceptionRequest? request,
HttpContext context,
IExceptionRepository repository,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
var existing = await repository.GetByIdAsync(id, cancellationToken);
@@ -425,7 +431,7 @@ public static class ExceptionEndpoints
{
Version = existing.Version + 1,
Status = ExceptionStatus.Revoked,
UpdatedAt = DateTimeOffset.UtcNow
UpdatedAt = timeProvider.GetUtcNow()
};
var result = await repository.UpdateAsync(

View File

@@ -39,6 +39,7 @@ public static class GateEndpoints
IBaselineSelector baselineSelector,
IGateBypassAuditor bypassAuditor,
IMemoryCache cache,
[FromServices] TimeProvider timeProvider,
ILogger<DriftGateEvaluator> logger,
CancellationToken cancellationToken) =>
{
@@ -79,12 +80,12 @@ public static class GateEndpoints
return Results.Ok(new GateEvaluateResponse
{
DecisionId = $"gate:{DateTimeOffset.UtcNow:yyyyMMddHHmmss}:{Guid.NewGuid():N}",
DecisionId = $"gate:{timeProvider.GetUtcNow():yyyyMMddHHmmss}:{Guid.NewGuid():N}",
Status = GateStatus.Pass,
ExitCode = GateExitCodes.Pass,
ImageDigest = request.ImageDigest,
BaselineRef = request.BaselineRef,
DecidedAt = DateTimeOffset.UtcNow,
DecidedAt = timeProvider.GetUtcNow(),
Summary = "First build - no baseline for comparison",
Advisory = "This appears to be a first build. Future builds will be compared against this baseline."
});
@@ -224,7 +225,8 @@ public static class GateEndpoints
.WithDescription("Retrieve a previous gate evaluation decision by ID");
// GET /api/v1/policy/gate/health - Health check for gate service
gates.MapGet("/health", () => Results.Ok(new { status = "healthy", timestamp = DateTimeOffset.UtcNow }))
gates.MapGet("/health", ([FromServices] TimeProvider timeProvider) =>
Results.Ok(new { status = "healthy", timestamp = timeProvider.GetUtcNow() }))
.WithName("GateHealth")
.WithDescription("Health check for the gate evaluation service");
}

View File

@@ -100,6 +100,7 @@ public static class GovernanceEndpoints
private static Task<IResult> GetSealedModeStatusAsync(
HttpContext httpContext,
[FromServices] TimeProvider timeProvider,
[FromQuery] string? tenantId)
{
var tenant = tenantId ?? GetTenantId(httpContext) ?? "default";
@@ -118,7 +119,7 @@ public static class GovernanceEndpoints
.Select(MapOverrideToResponse)
.ToList(),
VerificationStatus = "verified",
LastVerifiedAt = DateTimeOffset.UtcNow.ToString("O")
LastVerifiedAt = timeProvider.GetUtcNow().ToString("O")
};
return Task.FromResult(Results.Ok(response));
@@ -140,11 +141,12 @@ public static class GovernanceEndpoints
private static Task<IResult> ToggleSealedModeAsync(
HttpContext httpContext,
[FromServices] TimeProvider timeProvider,
SealedModeToggleRequest request)
{
var tenant = GetTenantId(httpContext) ?? "default";
var actor = GetActorId(httpContext) ?? "system";
var now = DateTimeOffset.UtcNow;
var now = timeProvider.GetUtcNow();
var state = SealedModeStates.GetOrAdd(tenant, _ => new SealedModeState());
@@ -173,7 +175,7 @@ public static class GovernanceEndpoints
// Audit
RecordAudit(tenant, actor, "sealed_mode_toggled", "sealed-mode", "system_config",
$"{(request.Enable ? "Enabled" : "Disabled")} sealed mode: {request.Reason}");
$"{(request.Enable ? "Enabled" : "Disabled")} sealed mode: {request.Reason}", timeProvider);
var response = new SealedModeStatusResponse
{
@@ -193,11 +195,12 @@ public static class GovernanceEndpoints
private static Task<IResult> CreateSealedModeOverrideAsync(
HttpContext httpContext,
[FromServices] TimeProvider timeProvider,
SealedModeOverrideRequest request)
{
var tenant = GetTenantId(httpContext) ?? "default";
var actor = GetActorId(httpContext) ?? "system";
var now = DateTimeOffset.UtcNow;
var now = timeProvider.GetUtcNow();
var overrideId = $"override-{Guid.NewGuid():N}";
var entity = new SealedModeOverrideEntity
@@ -217,13 +220,14 @@ public static class GovernanceEndpoints
Overrides[overrideId] = entity;
RecordAudit(tenant, actor, "sealed_mode_override_created", overrideId, "sealed_mode_override",
$"Created override for {request.Target}: {request.Reason}");
$"Created override for {request.Target}: {request.Reason}", timeProvider);
return Task.FromResult(Results.Ok(MapOverrideToResponse(entity)));
}
private static Task<IResult> RevokeSealedModeOverrideAsync(
HttpContext httpContext,
[FromServices] TimeProvider timeProvider,
string overrideId,
RevokeOverrideRequest request)
{
@@ -243,7 +247,7 @@ public static class GovernanceEndpoints
Overrides[overrideId] = entity;
RecordAudit(tenant, actor, "sealed_mode_override_revoked", overrideId, "sealed_mode_override",
$"Revoked override: {request.Reason}");
$"Revoked override: {request.Reason}", timeProvider);
return Task.FromResult(Results.NoContent());
}
@@ -289,11 +293,12 @@ public static class GovernanceEndpoints
private static Task<IResult> CreateRiskProfileAsync(
HttpContext httpContext,
[FromServices] TimeProvider timeProvider,
CreateRiskProfileRequest request)
{
var tenant = GetTenantId(httpContext) ?? "default";
var actor = GetActorId(httpContext) ?? "system";
var now = DateTimeOffset.UtcNow;
var now = timeProvider.GetUtcNow();
var profileId = $"profile-{Guid.NewGuid():N}";
var entity = new RiskProfileEntity
@@ -317,19 +322,20 @@ public static class GovernanceEndpoints
RiskProfiles[profileId] = entity;
RecordAudit(tenant, actor, "risk_profile_created", profileId, "risk_profile",
$"Created risk profile: {request.Name}");
$"Created risk profile: {request.Name}", timeProvider);
return Task.FromResult(Results.Created($"/api/v1/governance/risk-profiles/{profileId}", MapProfileToResponse(entity)));
}
private static Task<IResult> UpdateRiskProfileAsync(
HttpContext httpContext,
[FromServices] TimeProvider timeProvider,
string profileId,
UpdateRiskProfileRequest request)
{
var tenant = GetTenantId(httpContext) ?? "default";
var actor = GetActorId(httpContext) ?? "system";
var now = DateTimeOffset.UtcNow;
var now = timeProvider.GetUtcNow();
if (!RiskProfiles.TryGetValue(profileId, out var existing))
{
@@ -354,13 +360,14 @@ public static class GovernanceEndpoints
RiskProfiles[profileId] = entity;
RecordAudit(tenant, actor, "risk_profile_updated", profileId, "risk_profile",
$"Updated risk profile: {entity.Name}");
$"Updated risk profile: {entity.Name}", timeProvider);
return Task.FromResult(Results.Ok(MapProfileToResponse(entity)));
}
private static Task<IResult> DeleteRiskProfileAsync(
HttpContext httpContext,
[FromServices] TimeProvider timeProvider,
string profileId)
{
var tenant = GetTenantId(httpContext) ?? "default";
@@ -376,18 +383,19 @@ public static class GovernanceEndpoints
}
RecordAudit(tenant, actor, "risk_profile_deleted", profileId, "risk_profile",
$"Deleted risk profile: {removed.Name}");
$"Deleted risk profile: {removed.Name}", timeProvider);
return Task.FromResult(Results.NoContent());
}
private static Task<IResult> ActivateRiskProfileAsync(
HttpContext httpContext,
[FromServices] TimeProvider timeProvider,
string profileId)
{
var tenant = GetTenantId(httpContext) ?? "default";
var actor = GetActorId(httpContext) ?? "system";
var now = DateTimeOffset.UtcNow;
var now = timeProvider.GetUtcNow();
if (!RiskProfiles.TryGetValue(profileId, out var existing))
{
@@ -408,19 +416,20 @@ public static class GovernanceEndpoints
RiskProfiles[profileId] = entity;
RecordAudit(tenant, actor, "risk_profile_activated", profileId, "risk_profile",
$"Activated risk profile: {entity.Name}");
$"Activated risk profile: {entity.Name}", timeProvider);
return Task.FromResult(Results.Ok(MapProfileToResponse(entity)));
}
private static Task<IResult> DeprecateRiskProfileAsync(
HttpContext httpContext,
[FromServices] TimeProvider timeProvider,
string profileId,
DeprecateProfileRequest request)
{
var tenant = GetTenantId(httpContext) ?? "default";
var actor = GetActorId(httpContext) ?? "system";
var now = DateTimeOffset.UtcNow;
var now = timeProvider.GetUtcNow();
if (!RiskProfiles.TryGetValue(profileId, out var existing))
{
@@ -442,7 +451,7 @@ public static class GovernanceEndpoints
RiskProfiles[profileId] = entity;
RecordAudit(tenant, actor, "risk_profile_deprecated", profileId, "risk_profile",
$"Deprecated risk profile: {entity.Name} - {request.Reason}");
$"Deprecated risk profile: {entity.Name} - {request.Reason}", timeProvider);
return Task.FromResult(Results.Ok(MapProfileToResponse(entity)));
}
@@ -582,7 +591,7 @@ public static class GovernanceEndpoints
?? httpContext.Request.Headers["X-StellaOps-Actor"].FirstOrDefault();
}
private static void RecordAudit(string tenantId, string actor, string eventType, string targetId, string targetType, string summary)
private static void RecordAudit(string tenantId, string actor, string eventType, string targetId, string targetType, string summary, TimeProvider timeProvider)
{
var id = $"audit-{Guid.NewGuid():N}";
AuditEntries[id] = new GovernanceAuditEntry
@@ -590,7 +599,7 @@ public static class GovernanceEndpoints
Id = id,
TenantId = tenantId,
Type = eventType,
Timestamp = DateTimeOffset.UtcNow.ToString("O"),
Timestamp = timeProvider.GetUtcNow().ToString("O"),
Actor = actor,
ActorType = "user",
TargetResource = targetId,

View File

@@ -50,6 +50,7 @@ internal static class RegistryWebhookEndpoints
private static async Task<Results<Accepted<WebhookAcceptedResponse>, ProblemHttpResult>> HandleDockerRegistryWebhook(
[FromBody] DockerRegistryNotification notification,
IGateEvaluationQueue evaluationQueue,
[FromServices] TimeProvider timeProvider,
ILogger<RegistryWebhookEndpointMarker> logger,
CancellationToken ct)
{
@@ -77,7 +78,7 @@ internal static class RegistryWebhookEndpoints
Tag = evt.Target.Tag,
RegistryUrl = evt.Request?.Host,
Source = "docker-registry",
Timestamp = evt.Timestamp ?? DateTimeOffset.UtcNow
Timestamp = evt.Timestamp ?? timeProvider.GetUtcNow()
}, ct);
jobs.Add(jobId);
@@ -100,6 +101,7 @@ internal static class RegistryWebhookEndpoints
private static async Task<Results<Accepted<WebhookAcceptedResponse>, ProblemHttpResult>> HandleHarborWebhook(
[FromBody] HarborWebhookEvent notification,
IGateEvaluationQueue evaluationQueue,
[FromServices] TimeProvider timeProvider,
ILogger<RegistryWebhookEndpointMarker> logger,
CancellationToken ct)
{
@@ -136,7 +138,7 @@ internal static class RegistryWebhookEndpoints
Tag = resource.Tag,
RegistryUrl = notification.EventData.Repository?.RepoFullName,
Source = "harbor",
Timestamp = notification.OccurAt ?? DateTimeOffset.UtcNow
Timestamp = notification.OccurAt ?? timeProvider.GetUtcNow()
}, ct);
jobs.Add(jobId);
@@ -159,6 +161,7 @@ internal static class RegistryWebhookEndpoints
private static async Task<Results<Accepted<WebhookAcceptedResponse>, ProblemHttpResult>> HandleGenericWebhook(
[FromBody] GenericRegistryWebhook notification,
IGateEvaluationQueue evaluationQueue,
[FromServices] TimeProvider timeProvider,
ILogger<RegistryWebhookEndpointMarker> logger,
CancellationToken ct)
{
@@ -177,7 +180,7 @@ internal static class RegistryWebhookEndpoints
RegistryUrl = notification.RegistryUrl,
BaselineRef = notification.BaselineRef,
Source = notification.Source ?? "generic",
Timestamp = DateTimeOffset.UtcNow
Timestamp = timeProvider.GetUtcNow()
}, ct);
logger.LogInformation(

View File

@@ -21,11 +21,15 @@ public sealed class InMemoryGateEvaluationQueue : IGateEvaluationQueue
{
private readonly Channel<GateEvaluationJob> _channel;
private readonly ILogger<InMemoryGateEvaluationQueue> _logger;
private readonly TimeProvider _timeProvider;
public InMemoryGateEvaluationQueue(ILogger<InMemoryGateEvaluationQueue> logger)
public InMemoryGateEvaluationQueue(
ILogger<InMemoryGateEvaluationQueue> logger,
TimeProvider? timeProvider = null)
{
ArgumentNullException.ThrowIfNull(logger);
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
// Bounded channel to prevent unbounded memory growth
_channel = Channel.CreateBounded<GateEvaluationJob>(new BoundedChannelOptions(1000)
@@ -46,7 +50,7 @@ public sealed class InMemoryGateEvaluationQueue : IGateEvaluationQueue
{
JobId = jobId,
Request = request,
QueuedAt = DateTimeOffset.UtcNow
QueuedAt = _timeProvider.GetUtcNow()
};
await _channel.Writer.WriteAsync(job, cancellationToken).ConfigureAwait(false);
@@ -65,10 +69,10 @@ public sealed class InMemoryGateEvaluationQueue : IGateEvaluationQueue
/// </summary>
public ChannelReader<GateEvaluationJob> Reader => _channel.Reader;
private static string GenerateJobId()
private string GenerateJobId()
{
// Format: gate-{timestamp}-{random}
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var timestamp = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds();
var random = Guid.NewGuid().ToString("N")[..8];
return $"gate-{timestamp}-{random}";
}

View File

@@ -9,6 +9,12 @@ namespace StellaOps.Policy.Registry.Storage;
public sealed class InMemoryOverrideStore : IOverrideStore
{
private readonly ConcurrentDictionary<(Guid TenantId, Guid OverrideId), OverrideEntity> _overrides = new();
private readonly TimeProvider _timeProvider;
public InMemoryOverrideStore(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<OverrideEntity> CreateAsync(
Guid tenantId,
@@ -16,7 +22,7 @@ public sealed class InMemoryOverrideStore : IOverrideStore
string? createdBy = null,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
var overrideId = Guid.NewGuid();
var entity = new OverrideEntity
@@ -73,7 +79,7 @@ public sealed class InMemoryOverrideStore : IOverrideStore
return Task.FromResult<OverrideEntity?>(null);
}
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
var updated = existing with
{
Status = OverrideStatus.Approved,

View File

@@ -13,6 +13,12 @@ public sealed class InMemoryPolicyPackStore : IPolicyPackStore
{
private readonly ConcurrentDictionary<(Guid TenantId, Guid PackId), PolicyPackEntity> _packs = new();
private readonly ConcurrentDictionary<(Guid TenantId, Guid PackId), List<PolicyPackHistoryEntry>> _history = new();
private readonly TimeProvider _timeProvider;
public InMemoryPolicyPackStore(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<PolicyPackEntity> CreateAsync(
Guid tenantId,
@@ -20,7 +26,7 @@ public sealed class InMemoryPolicyPackStore : IPolicyPackStore
string? createdBy = null,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
var packId = Guid.NewGuid();
var entity = new PolicyPackEntity
@@ -130,7 +136,7 @@ public sealed class InMemoryPolicyPackStore : IPolicyPackStore
Description = request.Description ?? existing.Description,
Rules = request.Rules ?? existing.Rules,
Metadata = request.Metadata ?? existing.Metadata,
UpdatedAt = DateTimeOffset.UtcNow,
UpdatedAt = _timeProvider.GetUtcNow(),
UpdatedBy = updatedBy
};
@@ -178,7 +184,7 @@ public sealed class InMemoryPolicyPackStore : IPolicyPackStore
return Task.FromResult<PolicyPackEntity?>(null);
}
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
var updated = existing with
{
Status = newStatus,
@@ -228,7 +234,7 @@ public sealed class InMemoryPolicyPackStore : IPolicyPackStore
{
PackId = packId,
Action = action,
Timestamp = DateTimeOffset.UtcNow,
Timestamp = _timeProvider.GetUtcNow(),
PerformedBy = performedBy,
PreviousStatus = previousStatus,
NewStatus = newStatus,

View File

@@ -12,6 +12,12 @@ namespace StellaOps.Policy.Registry.Storage;
public sealed class InMemorySnapshotStore : ISnapshotStore
{
private readonly ConcurrentDictionary<(Guid TenantId, Guid SnapshotId), SnapshotEntity> _snapshots = new();
private readonly TimeProvider _timeProvider;
public InMemorySnapshotStore(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<SnapshotEntity> CreateAsync(
Guid tenantId,
@@ -19,7 +25,7 @@ public sealed class InMemorySnapshotStore : ISnapshotStore
string? createdBy = null,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
var snapshotId = Guid.NewGuid();
// Compute digest from pack IDs and timestamp for uniqueness

View File

@@ -9,6 +9,12 @@ namespace StellaOps.Policy.Registry.Storage;
public sealed class InMemoryVerificationPolicyStore : IVerificationPolicyStore
{
private readonly ConcurrentDictionary<(Guid TenantId, string PolicyId), VerificationPolicyEntity> _policies = new();
private readonly TimeProvider _timeProvider;
public InMemoryVerificationPolicyStore(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<VerificationPolicyEntity> CreateAsync(
Guid tenantId,
@@ -16,7 +22,7 @@ public sealed class InMemoryVerificationPolicyStore : IVerificationPolicyStore
string? createdBy = null,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
var entity = new VerificationPolicyEntity
{
@@ -102,7 +108,7 @@ public sealed class InMemoryVerificationPolicyStore : IVerificationPolicyStore
SignerRequirements = request.SignerRequirements ?? existing.SignerRequirements,
ValidityWindow = request.ValidityWindow ?? existing.ValidityWindow,
Metadata = request.Metadata ?? existing.Metadata,
UpdatedAt = DateTimeOffset.UtcNow,
UpdatedAt = _timeProvider.GetUtcNow(),
UpdatedBy = updatedBy
};

View File

@@ -9,13 +9,19 @@ namespace StellaOps.Policy.Registry.Storage;
public sealed class InMemoryViolationStore : IViolationStore
{
private readonly ConcurrentDictionary<(Guid TenantId, Guid ViolationId), ViolationEntity> _violations = new();
private readonly TimeProvider _timeProvider;
public InMemoryViolationStore(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<ViolationEntity> AppendAsync(
Guid tenantId,
CreateViolationRequest request,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
var violationId = Guid.NewGuid();
var entity = new ViolationEntity
@@ -42,7 +48,7 @@ public sealed class InMemoryViolationStore : IViolationStore
IReadOnlyList<CreateViolationRequest> requests,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
int created = 0;
int failed = 0;
var errors = new List<BatchError>();

View File

@@ -1,6 +1,7 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Determinism;
using StellaOps.Policy.Persistence;
using StellaOps.Policy.Persistence.Postgres;
using StellaOps.Policy.Unknowns.Models;
@@ -35,7 +36,7 @@ public sealed class UnknownsRepositoryTests : IAsyncLifetime
public async Task CreateAndGetById_RoundTripsReasonCodeAndEvidence()
{
await using var connection = await _dataSource.OpenConnectionAsync(_tenantId.ToString());
var repository = new UnknownsRepository(connection);
var repository = new UnknownsRepository(connection, TimeProvider.System, SystemGuidProvider.Instance);
var now = new DateTimeOffset(2025, 1, 2, 3, 4, 5, TimeSpan.Zero);
var unknown = CreateUnknown(
@@ -65,7 +66,7 @@ public sealed class UnknownsRepositoryTests : IAsyncLifetime
public async Task UpdateAsync_PersistsReasonCodeAndAssumptions()
{
await using var connection = await _dataSource.OpenConnectionAsync(_tenantId.ToString());
var repository = new UnknownsRepository(connection);
var repository = new UnknownsRepository(connection, TimeProvider.System, SystemGuidProvider.Instance);
var now = new DateTimeOffset(2025, 2, 3, 4, 5, 6, TimeSpan.Zero);
var unknown = CreateUnknown(

View File

@@ -33,7 +33,7 @@ rules:
var snapshotRepo = new InMemoryPolicySnapshotRepository();
var auditRepo = new InMemoryPolicyAuditRepository();
var timeProvider = new FakeTimeProvider();
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, timeProvider, NullLogger<PolicySnapshotStore>.Instance);
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, timeProvider, null, NullLogger<PolicySnapshotStore>.Instance);
await store.SaveAsync(new PolicySnapshotContent(yaml, PolicyDocumentFormat.Yaml, "tester", null, null), CancellationToken.None);
@@ -80,7 +80,7 @@ rules:
var snapshotRepo = new InMemoryPolicySnapshotRepository();
var auditRepo = new InMemoryPolicyAuditRepository();
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance);
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, TimeProvider.System, null, NullLogger<PolicySnapshotStore>.Instance);
var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.Instance);
var findings = ImmutableArray.Create(
@@ -111,7 +111,7 @@ rules:
{
var snapshotRepo = new InMemoryPolicySnapshotRepository();
var auditRepo = new InMemoryPolicyAuditRepository();
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance);
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, TimeProvider.System, null, NullLogger<PolicySnapshotStore>.Instance);
var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.Instance);
const string invalid = "version: 1.0";
@@ -159,7 +159,7 @@ rules:
Assert.False(binding.Document.Rules[0].Metadata.ContainsKey("quiet"));
Assert.True(binding.Document.Rules[0].Action.Quiet);
var store = new PolicySnapshotStore(new InMemoryPolicySnapshotRepository(), new InMemoryPolicyAuditRepository(), TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance);
var store = new PolicySnapshotStore(new InMemoryPolicySnapshotRepository(), new InMemoryPolicyAuditRepository(), TimeProvider.System, null, NullLogger<PolicySnapshotStore>.Instance);
await store.SaveAsync(new PolicySnapshotContent(yaml, PolicyDocumentFormat.Yaml, "tester", null, "quiet test"), CancellationToken.None);
var snapshot = await store.GetLatestAsync();
Assert.NotNull(snapshot);

View File

@@ -25,7 +25,7 @@ rules:
var snapshotRepo = new InMemoryPolicySnapshotRepository();
var auditRepo = new InMemoryPolicyAuditRepository();
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 10, 0, 0, TimeSpan.Zero));
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, timeProvider, NullLogger<PolicySnapshotStore>.Instance);
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, timeProvider, null, NullLogger<PolicySnapshotStore>.Instance);
var content = new PolicySnapshotContent(BasePolicyYaml, PolicyDocumentFormat.Yaml, "cli", "test", null);
@@ -56,7 +56,7 @@ rules:
var snapshotRepo = new InMemoryPolicySnapshotRepository();
var auditRepo = new InMemoryPolicyAuditRepository();
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 18, 10, 0, 0, TimeSpan.Zero));
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, timeProvider, NullLogger<PolicySnapshotStore>.Instance);
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, timeProvider, null, NullLogger<PolicySnapshotStore>.Instance);
var content = new PolicySnapshotContent(BasePolicyYaml, PolicyDocumentFormat.Yaml, "cli", "test", null);
var first = await store.SaveAsync(content, CancellationToken.None);
@@ -81,7 +81,7 @@ rules:
{
var snapshotRepo = new InMemoryPolicySnapshotRepository();
var auditRepo = new InMemoryPolicyAuditRepository();
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, TimeProvider.System, NullLogger<PolicySnapshotStore>.Instance);
var store = new PolicySnapshotStore(snapshotRepo, auditRepo, TimeProvider.System, null, NullLogger<PolicySnapshotStore>.Instance);
const string invalidYaml = "version: '1.0'\nrules: []";
var content = new PolicySnapshotContent(invalidYaml, PolicyDocumentFormat.Yaml, null, null, null);

View File

@@ -32,6 +32,7 @@ public sealed class ReplayEngineTests
_snapshotService,
sourceResolver,
verdictComparer,
null,
NullLogger<ReplayEngine>.Instance);
}

View File

@@ -7,11 +7,11 @@ using StellaOps.SbomService.Models;
using StellaOps.TestKit;
namespace StellaOps.SbomService.Tests;
public class EntrypointEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
public class EntrypointEndpointsTests : IClassFixture<WebApplicationFactory<StellaOps.SbomService.Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly WebApplicationFactory<StellaOps.SbomService.Program> _factory;
public EntrypointEndpointsTests(WebApplicationFactory<Program> factory)
public EntrypointEndpointsTests(WebApplicationFactory<StellaOps.SbomService.Program> factory)
{
_factory = factory;
}

View File

@@ -9,11 +9,11 @@ using Xunit;
using StellaOps.TestKit;
namespace StellaOps.SbomService.Tests;
public class OrchestratorEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
public class OrchestratorEndpointsTests : IClassFixture<WebApplicationFactory<StellaOps.SbomService.Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly WebApplicationFactory<StellaOps.SbomService.Program> _factory;
public OrchestratorEndpointsTests(WebApplicationFactory<Program> factory)
public OrchestratorEndpointsTests(WebApplicationFactory<StellaOps.SbomService.Program> factory)
{
_factory = factory;
}

View File

@@ -13,11 +13,11 @@ using Xunit;
using StellaOps.TestKit;
namespace StellaOps.SbomService.Tests;
public class ProjectionEndpointTests : IClassFixture<WebApplicationFactory<Program>>
public class ProjectionEndpointTests : IClassFixture<WebApplicationFactory<StellaOps.SbomService.Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly WebApplicationFactory<StellaOps.SbomService.Program> _factory;
public ProjectionEndpointTests(WebApplicationFactory<Program> factory)
public ProjectionEndpointTests(WebApplicationFactory<StellaOps.SbomService.Program> factory)
{
var contentRoot = ResolveContentRoot();
_factory = factory.WithWebHostBuilder(builder =>

View File

@@ -8,11 +8,11 @@ using Xunit;
using StellaOps.TestKit;
namespace StellaOps.SbomService.Tests;
public class ResolverFeedExportTests : IClassFixture<WebApplicationFactory<Program>>
public class ResolverFeedExportTests : IClassFixture<WebApplicationFactory<StellaOps.SbomService.Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly WebApplicationFactory<StellaOps.SbomService.Program> _factory;
public ResolverFeedExportTests(WebApplicationFactory<Program> factory)
public ResolverFeedExportTests(WebApplicationFactory<StellaOps.SbomService.Program> factory)
{
_factory = factory;
}

View File

@@ -8,11 +8,11 @@ using Xunit;
using StellaOps.TestKit;
namespace StellaOps.SbomService.Tests;
public class SbomAssetEventsTests : IClassFixture<WebApplicationFactory<Program>>
public class SbomAssetEventsTests : IClassFixture<WebApplicationFactory<StellaOps.SbomService.Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly WebApplicationFactory<StellaOps.SbomService.Program> _factory;
public SbomAssetEventsTests(WebApplicationFactory<Program> factory)
public SbomAssetEventsTests(WebApplicationFactory<StellaOps.SbomService.Program> factory)
{
_factory = factory;
}

View File

@@ -8,11 +8,11 @@ using Xunit;
using StellaOps.TestKit;
namespace StellaOps.SbomService.Tests;
public class SbomEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
public class SbomEndpointsTests : IClassFixture<WebApplicationFactory<StellaOps.SbomService.Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly WebApplicationFactory<StellaOps.SbomService.Program> _factory;
public SbomEndpointsTests(WebApplicationFactory<Program> factory)
public SbomEndpointsTests(WebApplicationFactory<StellaOps.SbomService.Program> factory)
{
_factory = factory.WithWebHostBuilder(_ => { });
}

View File

@@ -9,11 +9,11 @@ using Xunit;
using StellaOps.TestKit;
namespace StellaOps.SbomService.Tests;
public class SbomEventEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
public class SbomEventEndpointsTests : IClassFixture<WebApplicationFactory<StellaOps.SbomService.Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly WebApplicationFactory<StellaOps.SbomService.Program> _factory;
public SbomEventEndpointsTests(WebApplicationFactory<Program> factory)
public SbomEventEndpointsTests(WebApplicationFactory<StellaOps.SbomService.Program> factory)
{
_factory = factory.WithWebHostBuilder(_ => { });
}

View File

@@ -8,11 +8,11 @@ using Xunit;
using StellaOps.TestKit;
namespace StellaOps.SbomService.Tests;
public class SbomInventoryEventsTests : IClassFixture<WebApplicationFactory<Program>>
public class SbomInventoryEventsTests : IClassFixture<WebApplicationFactory<StellaOps.SbomService.Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly WebApplicationFactory<StellaOps.SbomService.Program> _factory;
public SbomInventoryEventsTests(WebApplicationFactory<Program> factory)
public SbomInventoryEventsTests(WebApplicationFactory<StellaOps.SbomService.Program> factory)
{
_factory = factory;
}

View File

@@ -10,11 +10,11 @@ using Xunit;
using StellaOps.TestKit;
namespace StellaOps.SbomService.Tests;
public sealed class SbomLedgerEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
public sealed class SbomLedgerEndpointsTests : IClassFixture<WebApplicationFactory<StellaOps.SbomService.Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly WebApplicationFactory<StellaOps.SbomService.Program> _factory;
public SbomLedgerEndpointsTests(WebApplicationFactory<Program> factory)
public SbomLedgerEndpointsTests(WebApplicationFactory<StellaOps.SbomService.Program> factory)
{
_factory = factory.WithWebHostBuilder(_ => { });
}

View File

@@ -1311,5 +1311,8 @@ app.MapPost("/internal/orchestrator/watermarks", async Task<IResult> (
app.Run();
// Program class public for WebApplicationFactory<Program>
public partial class Program;
// Program class in namespace to avoid conflicts with other assemblies
namespace StellaOps.SbomService
{
public partial class Program;
}

View File

@@ -6,9 +6,11 @@ namespace StellaOps.SbomService.Repositories;
internal sealed class InMemoryOrchestratorRepository : IOrchestratorRepository
{
private readonly ConcurrentDictionary<string, List<OrchestratorSource>> _sources = new(StringComparer.Ordinal);
private readonly TimeProvider _timeProvider;
public InMemoryOrchestratorRepository()
public InMemoryOrchestratorRepository(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
Seed();
}
@@ -37,7 +39,7 @@ internal sealed class InMemoryOrchestratorRepository : IOrchestratorRepository
sourceId,
request.ArtifactDigest.Trim(),
request.SourceType.Trim(),
DateTimeOffset.UtcNow,
_timeProvider.GetUtcNow(),
request.Metadata.Trim());
// Idempotent on (tenant, artifactDigest, sourceType)

View File

@@ -1,13 +1,28 @@
using System;
namespace StellaOps.SbomService.Services;
/// <summary>
/// Provides the current time abstraction - delegates to TimeProvider.
/// </summary>
/// <remarks>
/// Deprecated: Prefer injecting TimeProvider directly. This interface is kept
/// for backward compatibility during migration.
/// </remarks>
public interface IClock
{
DateTimeOffset UtcNow { get; }
}
/// <summary>
/// Default IClock implementation that delegates to TimeProvider.
/// </summary>
public sealed class SystemClock : IClock
{
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
private readonly TimeProvider _timeProvider;
public SystemClock(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public DateTimeOffset UtcNow => _timeProvider.GetUtcNow();
}

View File

@@ -136,9 +136,9 @@ public sealed record ReplayVerificationResult
public ImmutableArray<ReplayFieldDrift> Drifts { get; init; } = ImmutableArray<ReplayFieldDrift>.Empty;
/// <summary>
/// When the verification was performed.
/// When the verification was performed. Must be explicitly set by calling code.
/// </summary>
public DateTimeOffset VerifiedAt { get; init; } = DateTimeOffset.UtcNow;
public required DateTimeOffset VerifiedAt { get; init; }
/// <summary>
/// Optional message with additional context.
@@ -249,7 +249,7 @@ public sealed record ReplayDriftAnalysis
public required string DriftSummary { get; init; }
/// <summary>
/// When the analysis was performed.
/// When the analysis was performed. Must be explicitly set by calling code.
/// </summary>
public DateTimeOffset AnalyzedAt { get; init; } = DateTimeOffset.UtcNow;
public required DateTimeOffset AnalyzedAt { get; init; }
}

View File

@@ -26,19 +26,22 @@ internal sealed class LineageCompareService : ILineageCompareService
private readonly IVexDeltaRepository? _vexDeltaRepository;
private readonly ILineageCompareCache? _cache;
private readonly ILogger<LineageCompareService> _logger;
private readonly TimeProvider _timeProvider;
public LineageCompareService(
ISbomLineageGraphService lineageService,
ISbomLedgerService ledgerService,
ILogger<LineageCompareService> logger,
IVexDeltaRepository? vexDeltaRepository = null,
ILineageCompareCache? cache = null)
ILineageCompareCache? cache = null,
TimeProvider? timeProvider = null)
{
_lineageService = lineageService ?? throw new ArgumentNullException(nameof(lineageService));
_ledgerService = ledgerService ?? throw new ArgumentNullException(nameof(ledgerService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_vexDeltaRepository = vexDeltaRepository;
_cache = cache;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
@@ -139,7 +142,7 @@ internal sealed class LineageCompareService : ILineageCompareService
FromDigest = fromDigest,
ToDigest = toDigest,
TenantId = tenantId,
ComputedAt = DateTimeOffset.UtcNow,
ComputedAt = _timeProvider.GetUtcNow(),
FromArtifact = fromArtifact,
ToArtifact = toArtifact,
Summary = summary,

View File

@@ -21,16 +21,19 @@ internal sealed class LineageExportService : ILineageExportService
private readonly ISbomLineageGraphService _lineageService;
private readonly IReplayHashService? _replayHashService;
private readonly ILogger<LineageExportService> _logger;
private readonly TimeProvider _timeProvider;
private const long MaxExportSizeBytes = 50 * 1024 * 1024; // 50MB limit
public LineageExportService(
ISbomLineageGraphService lineageService,
ILogger<LineageExportService> logger,
IReplayHashService? replayHashService = null)
IReplayHashService? replayHashService = null,
TimeProvider? timeProvider = null)
{
_lineageService = lineageService;
_logger = logger;
_replayHashService = replayHashService;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<LineageExportResponse?> ExportAsync(
@@ -59,7 +62,7 @@ internal sealed class LineageExportService : ILineageExportService
Version = "1.0",
FromDigest = request.FromDigest,
ToDigest = request.ToDigest,
GeneratedAt = DateTimeOffset.UtcNow,
GeneratedAt = _timeProvider.GetUtcNow(),
ReplayHash = diff.ReplayHash ?? ComputeFallbackHash(request.FromDigest, request.ToDigest),
SbomDiff = request.IncludeSbomDiff ? diff.SbomDiff?.Summary : null,
VexDeltas = request.IncludeVexDeltas ? diff.VexDiff : null,
@@ -91,7 +94,7 @@ internal sealed class LineageExportService : ILineageExportService
// Generate export ID and URL
var exportId = Guid.NewGuid().ToString("N");
var downloadUrl = $"/api/v1/lineage/export/{exportId}/download";
var expiresAt = DateTimeOffset.UtcNow.AddHours(24);
var expiresAt = _timeProvider.GetUtcNow().AddHours(24);
// TODO: Store evidence pack for retrieval (file system, blob storage, etc.)
// For now, return metadata only
@@ -114,9 +117,9 @@ internal sealed class LineageExportService : ILineageExportService
};
}
private static string ComputeFallbackHash(string fromDigest, string toDigest)
private string ComputeFallbackHash(string fromDigest, string toDigest)
{
var input = $"{fromDigest}:{toDigest}:{DateTimeOffset.UtcNow:O}";
var input = $"{fromDigest}:{toDigest}:{_timeProvider.GetUtcNow():O}";
var bytes = Encoding.UTF8.GetBytes(input);
var hashBytes = SHA256.HashData(bytes);
return Convert.ToHexString(hashBytes).ToLowerInvariant();

View File

@@ -221,11 +221,13 @@ internal sealed class InMemoryLineageHoverCache : ILineageHoverCache
{
private readonly Dictionary<string, (SbomLineageHoverCard Card, DateTimeOffset ExpiresAt)> _cache = new();
private readonly LineageHoverCacheOptions _options;
private readonly TimeProvider _timeProvider;
private readonly object _lock = new();
public InMemoryLineageHoverCache(LineageHoverCacheOptions? options = null)
public InMemoryLineageHoverCache(LineageHoverCacheOptions? options = null, TimeProvider? timeProvider = null)
{
_options = options ?? new LineageHoverCacheOptions();
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<SbomLineageHoverCard?> GetAsync(string fromDigest, string toDigest, string tenantId, CancellationToken ct = default)
@@ -240,7 +242,7 @@ internal sealed class InMemoryLineageHoverCache : ILineageHoverCache
{
if (_cache.TryGetValue(key, out var entry))
{
if (entry.ExpiresAt > DateTimeOffset.UtcNow)
if (entry.ExpiresAt > _timeProvider.GetUtcNow())
{
return Task.FromResult<SbomLineageHoverCard?>(entry.Card);
}
@@ -260,7 +262,7 @@ internal sealed class InMemoryLineageHoverCache : ILineageHoverCache
}
var key = BuildKey(fromDigest, toDigest, tenantId);
var expiresAt = DateTimeOffset.UtcNow.Add(_options.Ttl);
var expiresAt = _timeProvider.GetUtcNow().Add(_options.Ttl);
lock (_lock)
{

View File

@@ -11,8 +11,8 @@ public sealed record OrchestratorControlState(
string Backpressure,
DateTimeOffset UpdatedAtUtc)
{
public static OrchestratorControlState Default(string tenantId) =>
new(tenantId, false, 0, "normal", DateTimeOffset.UtcNow);
public static OrchestratorControlState Default(string tenantId, TimeProvider? timeProvider = null) =>
new(tenantId, false, 0, "normal", (timeProvider ?? TimeProvider.System).GetUtcNow());
}
public sealed record OrchestratorControlRequest(
@@ -34,13 +34,18 @@ internal sealed class OrchestratorControlService : IOrchestratorControlService
private readonly Counter<long> _controlUpdates;
private readonly ObservableGauge<int> _throttleGauge;
private readonly ObservableGauge<int> _pausedGauge;
private readonly TimeProvider _timeProvider;
private readonly ConcurrentDictionary<string, OrchestratorControlState> _cache = new(StringComparer.Ordinal);
public OrchestratorControlService(IOrchestratorControlRepository repository, Meter meter)
public OrchestratorControlService(
IOrchestratorControlRepository repository,
Meter meter,
TimeProvider? timeProvider = null)
{
_repository = repository;
_meter = meter;
_timeProvider = timeProvider ?? TimeProvider.System;
_controlUpdates = meter.CreateCounter<long>("sbom_orchestrator_control_updates");
_throttleGauge = meter.CreateObservableGauge("sbom_orchestrator_throttle_percent", ObserveThrottle);
_pausedGauge = meter.CreateObservableGauge("sbom_orchestrator_paused", ObservePaused);
@@ -66,7 +71,7 @@ internal sealed class OrchestratorControlService : IOrchestratorControlService
Paused: request.Paused ?? current.Paused,
ThrottlePercent: throttle,
Backpressure: string.IsNullOrWhiteSpace(request.Backpressure) ? current.Backpressure : request.Backpressure!.Trim().ToLowerInvariant(),
UpdatedAtUtc: DateTimeOffset.UtcNow);
UpdatedAtUtc: _timeProvider.GetUtcNow());
await _repository.SetAsync(updated, cancellationToken);
_cache[updated.TenantId] = updated;

View File

@@ -30,15 +30,18 @@ public sealed class RegistrySourceService : IRegistrySourceService
private readonly IRegistrySourceRepository _sourceRepository;
private readonly IRegistrySourceRunRepository _runRepository;
private readonly ILogger<RegistrySourceService> _logger;
private readonly TimeProvider _timeProvider;
public RegistrySourceService(
IRegistrySourceRepository sourceRepository,
IRegistrySourceRunRepository runRepository,
ILogger<RegistrySourceService> logger)
ILogger<RegistrySourceService> logger,
TimeProvider? timeProvider = null)
{
_sourceRepository = sourceRepository;
_runRepository = runRepository;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
@@ -125,7 +128,7 @@ public sealed class RegistrySourceService : IRegistrySourceService
if (request.Status.HasValue) source.Status = request.Status.Value;
if (request.Tags is not null) source.Tags = request.Tags.ToList();
source.UpdatedAt = DateTimeOffset.UtcNow;
source.UpdatedAt = _timeProvider.GetUtcNow();
source.UpdatedBy = userId;
var updated = await _sourceRepository.UpdateAsync(source, cancellationToken);
@@ -156,23 +159,23 @@ public sealed class RegistrySourceService : IRegistrySourceService
var source = await _sourceRepository.GetByIdAsync(id, cancellationToken);
if (source is null)
{
return new TestRegistrySourceResponse(id, false, "Registry source not found", null, TimeSpan.Zero, DateTimeOffset.UtcNow);
return new TestRegistrySourceResponse(id, false, "Registry source not found", null, TimeSpan.Zero, _timeProvider.GetUtcNow());
}
var startTime = DateTimeOffset.UtcNow;
var startTime = _timeProvider.GetUtcNow();
// TODO: Implement actual registry connection test
// For now, simulate a successful test
await Task.Delay(100, cancellationToken);
var duration = DateTimeOffset.UtcNow - startTime;
var duration = _timeProvider.GetUtcNow() - startTime;
// Update source status
var newStatus = RegistrySourceStatus.Active;
if (source.Status != newStatus)
{
source.Status = newStatus;
source.UpdatedAt = DateTimeOffset.UtcNow;
source.UpdatedAt = _timeProvider.GetUtcNow();
await _sourceRepository.UpdateAsync(source, cancellationToken);
}
@@ -188,7 +191,7 @@ public sealed class RegistrySourceService : IRegistrySourceService
["type"] = source.Type.ToString()
},
duration,
DateTimeOffset.UtcNow);
_timeProvider.GetUtcNow());
}
/// <summary>
@@ -209,7 +212,7 @@ public sealed class RegistrySourceService : IRegistrySourceService
Status = RegistryRunStatus.Queued,
TriggerType = triggerType,
TriggerMetadata = triggerMetadata,
StartedAt = DateTimeOffset.UtcNow
StartedAt = _timeProvider.GetUtcNow()
};
var created = await _runRepository.CreateAsync(run, cancellationToken);
@@ -229,7 +232,7 @@ public sealed class RegistrySourceService : IRegistrySourceService
if (source is null) return null;
source.Status = RegistrySourceStatus.Paused;
source.UpdatedAt = DateTimeOffset.UtcNow;
source.UpdatedAt = _timeProvider.GetUtcNow();
source.UpdatedBy = userId;
var updated = await _sourceRepository.UpdateAsync(source, cancellationToken);
@@ -252,7 +255,7 @@ public sealed class RegistrySourceService : IRegistrySourceService
}
source.Status = RegistrySourceStatus.Active;
source.UpdatedAt = DateTimeOffset.UtcNow;
source.UpdatedAt = _timeProvider.GetUtcNow();
source.UpdatedBy = userId;
var updated = await _sourceRepository.UpdateAsync(source, cancellationToken);

View File

@@ -74,6 +74,7 @@ internal sealed class ReplayVerificationService : IReplayVerificationService
ExpectedHash = request.ReplayHash,
ComputedHash = string.Empty,
Status = ReplayVerificationStatus.InputsNotFound,
VerifiedAt = _clock.UtcNow,
Error = "Unable to determine verification inputs. Provide explicit inputs or ensure hash is stored."
};
}
@@ -119,6 +120,7 @@ internal sealed class ReplayVerificationService : IReplayVerificationService
ExpectedHash = request.ReplayHash,
ComputedHash = string.Empty,
Status = ReplayVerificationStatus.Error,
VerifiedAt = _clock.UtcNow,
Error = ex.Message
};
}

View File

@@ -16,6 +16,12 @@ public interface IWatermarkService
internal sealed class InMemoryWatermarkService : IWatermarkService
{
private readonly ConcurrentDictionary<string, WatermarkState> _watermarks = new(StringComparer.Ordinal);
private readonly TimeProvider _timeProvider;
public InMemoryWatermarkService(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public Task<WatermarkState> GetAsync(string tenantId, CancellationToken cancellationToken)
{
@@ -24,14 +30,14 @@ internal sealed class InMemoryWatermarkService : IWatermarkService
return Task.FromResult(state);
}
var created = new WatermarkState(tenantId, string.Empty, DateTimeOffset.UtcNow);
var created = new WatermarkState(tenantId, string.Empty, _timeProvider.GetUtcNow());
_watermarks[tenantId] = created;
return Task.FromResult(created);
}
public Task<WatermarkState> SetAsync(string tenantId, string watermark, CancellationToken cancellationToken)
{
var state = new WatermarkState(tenantId, watermark, DateTimeOffset.UtcNow);
var state = new WatermarkState(tenantId, watermark, _timeProvider.GetUtcNow());
_watermarks[tenantId] = state;
return Task.FromResult(state);
}

View File

@@ -19,5 +19,6 @@
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="StellaOps.SbomService.Tests" />
</ItemGroup>
</Project>

View File

@@ -13,12 +13,15 @@ public sealed class SbomLineageEdgeRepository : RepositoryBase<LineageDataSource
private const string Schema = "sbom";
private const string Table = "sbom_lineage_edges";
private const string FullTable = $"{Schema}.{Table}";
private readonly TimeProvider _timeProvider;
public SbomLineageEdgeRepository(
LineageDataSource dataSource,
ILogger<SbomLineageEdgeRepository> logger)
ILogger<SbomLineageEdgeRepository> logger,
TimeProvider? timeProvider = null)
: base(dataSource, logger)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async ValueTask<LineageGraph> GetGraphAsync(
@@ -260,7 +263,7 @@ public sealed class SbomLineageEdgeRepository : RepositoryBase<LineageDataSource
ArtifactDigest: artifactDigest,
SbomVersionId: null,
SequenceNumber: 0,
CreatedAt: DateTimeOffset.UtcNow,
CreatedAt: _timeProvider.GetUtcNow(),
Metadata: null
);
}

View File

@@ -16,6 +16,7 @@ public sealed class LineageGraphService : ILineageGraphService
private readonly ISbomVerdictLinkRepository _verdictRepository;
private readonly IDistributedCache? _cache;
private readonly ILogger<LineageGraphService> _logger;
private readonly TimeProvider _timeProvider;
private static readonly TimeSpan CacheExpiry = TimeSpan.FromMinutes(10);
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
@@ -25,13 +26,15 @@ public sealed class LineageGraphService : ILineageGraphService
IVexDeltaRepository deltaRepository,
ISbomVerdictLinkRepository verdictRepository,
ILogger<LineageGraphService> logger,
IDistributedCache? cache = null)
IDistributedCache? cache = null,
TimeProvider? timeProvider = null)
{
_edgeRepository = edgeRepository;
_deltaRepository = deltaRepository;
_verdictRepository = verdictRepository;
_cache = cache;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async ValueTask<LineageGraphResponse> GetLineageAsync(
@@ -184,7 +187,7 @@ public sealed class LineageGraphService : ILineageGraphService
// Placeholder implementation
var downloadUrl = $"https://evidence.stellaops.example/exports/{Guid.NewGuid()}.tar.gz";
var expiresAt = DateTimeOffset.UtcNow.AddHours(24);
var expiresAt = _timeProvider.GetUtcNow().AddHours(24);
return new ExportResult(
DownloadUrl: downloadUrl,

View File

@@ -11,11 +11,16 @@ namespace StellaOps.SbomService.Persistence.Postgres.Repositories;
/// </summary>
public sealed class PostgresOrchestratorRepository : RepositoryBase<SbomServiceDataSource>, IOrchestratorRepository
{
private readonly TimeProvider _timeProvider;
private bool _tableInitialized;
public PostgresOrchestratorRepository(SbomServiceDataSource dataSource, ILogger<PostgresOrchestratorRepository> logger)
public PostgresOrchestratorRepository(
SbomServiceDataSource dataSource,
ILogger<PostgresOrchestratorRepository> logger,
TimeProvider? timeProvider = null)
: base(dataSource, logger)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<IReadOnlyList<OrchestratorSource>> ListAsync(string tenantId, CancellationToken cancellationToken)
@@ -79,7 +84,7 @@ public sealed class PostgresOrchestratorRepository : RepositoryBase<SbomServiceD
var count = Convert.ToInt32(await countCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false));
var sourceId = $"src-{count + 1:D3}";
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
const string insertSql = @"
INSERT INTO sbom.orchestrator_sources (tenant_id, source_id, artifact_digest, source_type, created_at, metadata)

View File

@@ -78,9 +78,9 @@ public sealed record LineageEdge
public required string TenantId { get; init; }
/// <summary>
/// When the edge was created.
/// When the edge was created. Must be explicitly set by calling code.
/// </summary>
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Optional metadata about the relationship.

View File

@@ -94,9 +94,9 @@ public sealed record SbomVerdictLink
public required string TenantId { get; init; }
/// <summary>
/// When the link was created.
/// When the link was created. Must be explicitly set by calling code.
/// </summary>
public DateTimeOffset LinkedAt { get; init; } = DateTimeOffset.UtcNow;
public required DateTimeOffset LinkedAt { get; init; }
/// <summary>
/// SBOM artifact digest for cross-reference.

View File

@@ -44,7 +44,7 @@ public sealed class AdvisoryClient : IAdvisoryClient
var normalized = cveId.Trim().ToUpperInvariant();
var cacheKey = $"advisory:cve:{normalized}";
if (_cache.TryGetValue(cacheKey, out AdvisorySymbolMapping cached))
if (_cache.TryGetValue(cacheKey, out AdvisorySymbolMapping? cached) && cached is not null)
{
return cached;
}

View File

@@ -249,7 +249,7 @@ internal static class PythonDistributionLoader
return false;
}
private static void AddFileEvidence(LanguageAnalyzerContext context, string path, string source, ICollection<LanguageComponentEvidence> evidence)
private static void AddFileEvidence(LanguageAnalyzerContext context, string? path, string source, ICollection<LanguageComponentEvidence> evidence)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{

View File

@@ -14,7 +14,7 @@ public sealed class RiskScoreTests
[Fact]
public void RiskScore_Zero_ReturnsNegligibleLevel()
{
var score = RiskScore.Zero;
var score = RiskScore.Zero();
Assert.Equal(0.0f, score.OverallScore);
Assert.Equal(RiskCategory.Unknown, score.Category);

View File

@@ -34,7 +34,7 @@ public sealed class SurfaceValidatorRunnerTests
Array.Empty<string>(),
new SurfaceSecretsConfiguration("kubernetes", "", null, null, null, false),
string.Empty,
new SurfaceTlsConfiguration(null, null, null));
new SurfaceTlsConfiguration(null, null, null)) { CreatedAtUtc = DateTimeOffset.UtcNow };
var context = SurfaceValidationContext.Create(services, "TestComponent", environment);
@@ -60,7 +60,7 @@ public sealed class SurfaceValidatorRunnerTests
Array.Empty<string>(),
new SurfaceSecretsConfiguration("kubernetes", "tenant-a", null, "stellaops", null, false),
"tenant-a",
new SurfaceTlsConfiguration(null, null, null));
new SurfaceTlsConfiguration(null, null, null)) { CreatedAtUtc = DateTimeOffset.UtcNow };
var services = CreateServices();
var runner = services.GetRequiredService<ISurfaceValidatorRunner>();
@@ -86,7 +86,7 @@ public sealed class SurfaceValidatorRunnerTests
Array.Empty<string>(),
new SurfaceSecretsConfiguration("inline", "tenant-a", Root: null, Namespace: null, FallbackProvider: null, AllowInline: false),
"tenant-a",
new SurfaceTlsConfiguration(null, null, null));
new SurfaceTlsConfiguration(null, null, null)) { CreatedAtUtc = DateTimeOffset.UtcNow };
var services = CreateServices();
var runner = services.GetRequiredService<ISurfaceValidatorRunner>();
@@ -118,7 +118,7 @@ public sealed class SurfaceValidatorRunnerTests
Array.Empty<string>(),
new SurfaceSecretsConfiguration("file", "tenant-a", Root: missingRoot, Namespace: null, FallbackProvider: null, AllowInline: false),
"tenant-a",
new SurfaceTlsConfiguration(null, null, null));
new SurfaceTlsConfiguration(null, null, null)) { CreatedAtUtc = DateTimeOffset.UtcNow };
var services = CreateServices();
var runner = services.GetRequiredService<ISurfaceValidatorRunner>();

View File

@@ -49,7 +49,7 @@ builder.Services.AddOpenApi();
var routerOptions = builder.Configuration.GetSection("VexHub:Router").Get<StellaRouterOptionsBase>();
builder.Services.TryAddStellaRouter(
serviceName: "vexhub",
version: typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0",
version: System.Reflection.Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "1.0.0",
routerOptions: routerOptions);
var app = builder.Build();
@@ -83,3 +83,9 @@ app.MapGet("/health", () => Results.Ok(new { Status = "Healthy", Service = "VexH
app.TryRefreshStellaRouterEndpoints(routerOptions);
app.Run();
// Make Program class explicit to avoid conflicts with imported types
namespace StellaOps.VexHub.WebService
{
public partial class Program { }
}

View File

@@ -10,11 +10,11 @@ namespace StellaOps.VexHub.WebService.Tests.Integration;
/// Integration tests verifying VexHub API compatibility with Trivy and Grype.
/// These tests ensure the API endpoints return valid OpenVEX format that can be consumed by scanning tools.
/// </summary>
public sealed class VexExportCompatibilityTests : IClassFixture<WebApplicationFactory<Program>>
public sealed class VexExportCompatibilityTests : IClassFixture<WebApplicationFactory<StellaOps.VexHub.WebService.Program>>
{
private readonly HttpClient _client;
public VexExportCompatibilityTests(WebApplicationFactory<Program> factory)
public VexExportCompatibilityTests(WebApplicationFactory<StellaOps.VexHub.WebService.Program> factory)
{
_client = factory.CreateClient();
}

View File

@@ -165,14 +165,14 @@ internal static class NoiseGatingApiMapper
VulnerabilityId: entry.VulnerabilityId,
ProductKey: entry.ProductKey,
FromStatus: entry.FromStatus?.ToString(),
ToStatus: entry.ToStatus?.ToString(),
ToStatus: entry.ToStatus.ToString(),
FromConfidence: entry.FromConfidence,
ToConfidence: entry.ToConfidence,
Justification: entry.Justification?.ToString(),
FromRationaleClass: entry.FromRationaleClass,
ToRationaleClass: entry.ToRationaleClass,
Summary: entry.Summary,
ContributingSources: entry.ContributingSources?.ToList(),
ContributingSources: entry.ContributingSources.IsDefaultOrEmpty ? null : entry.ContributingSources.ToList(),
CreatedAt: entry.Timestamp);
}

View File

@@ -57,16 +57,27 @@ public sealed class NoiseGateService : INoiseGate
if (!opts.EdgeDeduplicationEnabled || !opts.Enabled)
{
// Return edges without deduplication - create minimal deduplicated wrappers
var passthrough = edges.Select(e => new DeduplicatedEdgeBuilder(
e.From, e.To, e.Why.Type, e.Why.Loc)
.WithConfidence(e.Why.Confidence)
.Build())
.ToList();
var passthrough = edges.Select(e =>
{
var key = new EdgeSemanticKey(e.From, e.To);
var builder = new DeduplicatedEdgeBuilder(key, e.From, e.To);
builder.AddSource(
"passthrough",
e.Why,
e.Why.Confidence,
_timeProvider.GetUtcNow());
return builder.Build();
}).ToList();
return Task.FromResult<IReadOnlyList<DeduplicatedEdge>>(passthrough);
}
var result = _edgeDeduplicator.Deduplicate(edges);
var result = _edgeDeduplicator.Deduplicate(
edges,
e => new EdgeSemanticKey(e.From, e.To),
e => "deduplicated",
e => e.Why.Confidence,
e => _timeProvider.GetUtcNow());
return Task.FromResult(result);
}
@@ -438,13 +449,13 @@ public sealed class NoiseGateService : INoiseGate
// Add sorted edges
var sortedEdges = edges
.OrderBy(e => e.SemanticKey, StringComparer.Ordinal)
.OrderBy(e => e.Key.ComputeKey(), StringComparer.Ordinal)
.ToList();
foreach (var edge in sortedEdges)
{
sb.Append('|');
sb.Append(edge.SemanticKey);
sb.Append(edge.Key.ComputeKey());
}
// Add sorted verdicts

View File

@@ -26,7 +26,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { inject } from '@angular/core';
import { SecretAlertSettings, SecretAlertDestination } from '../../models';
import { SecretAlertSettings, SecretAlertDestination, SecretSeverity } from '../../models';
import { SecretDetectionSettingsService } from '../../services';
@Component({
@@ -403,7 +403,16 @@ export class AlertDestinationConfigComponent {
private readonly settingsService = inject(SecretDetectionSettingsService);
private readonly snackBar = inject(MatSnackBar);
@Input() settings: SecretAlertSettings = { enabled: false, destinations: [] };
@Input() settings: SecretAlertSettings = {
enabled: false,
destinations: [],
minimumAlertSeverity: 'High',
maxAlertsPerScan: 100,
deduplicationWindowHours: 24,
includeFilePath: true,
includeMaskedValue: false,
includeImageRef: true,
};
@Input() tenantId = '';
@Output() settingsChange = new EventEmitter<SecretAlertSettings>();
@@ -421,7 +430,7 @@ export class AlertDestinationConfigComponent {
this.emitChange();
}
onMinSeverityChange(severity: string): void {
onMinSeverityChange(severity: SecretSeverity): void {
this.settings = { ...this.settings, minimumSeverity: severity };
this.emitChange();
}
@@ -448,6 +457,8 @@ export class AlertDestinationConfigComponent {
const newDest: SecretAlertDestination = {
id: crypto.randomUUID(),
name: '',
channelType: 'Webhook',
channelId: '',
type: 'webhook',
enabled: true,
config: {},
@@ -466,7 +477,7 @@ export class AlertDestinationConfigComponent {
this.updateDestination(index, { name: input.value });
}
onDestTypeChange(index: number, type: string): void {
onDestTypeChange(index: number, type: 'webhook' | 'slack' | 'email' | 'teams' | 'pagerduty'): void {
this.updateDestination(index, { type, config: {} });
}
@@ -478,7 +489,7 @@ export class AlertDestinationConfigComponent {
});
}
onDestSeverityChange(index: number, severities: string[]): void {
onDestSeverityChange(index: number, severities: SecretSeverity[]): void {
this.updateDestination(index, { severityFilter: severities });
}

View File

@@ -25,7 +25,7 @@ import { MatCardModule } from '@angular/material/card';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { SecretExceptionService } from '../../services';
import { SecretExceptionPattern } from '../../models';
import { SecretExceptionPattern, CreateExceptionRequest } from '../../models';
@Component({
selector: 'app-exception-manager',
@@ -398,7 +398,7 @@ export class ExceptionManagerComponent implements OnInit {
}
loadExceptions(): void {
this.exceptionService.loadExceptions(this.tenantId).subscribe();
this.exceptionService.listExceptions(this.tenantId).subscribe();
}
openCreateDialog(): void {
@@ -436,17 +436,18 @@ export class ExceptionManagerComponent implements OnInit {
const formValue = this.exceptionForm.value;
const existing = this.editingException();
const payload: Partial<SecretExceptionPattern> = {
name: formValue.name,
const payload: CreateExceptionRequest = {
name: formValue.name ?? '',
pattern: formValue.pattern ?? '',
reason: formValue.description ?? 'Exception created via UI',
matchType: formValue.matchType ?? 'literal',
target: formValue.target ?? 'value',
enabled: formValue.enabled ?? true,
description: formValue.description || undefined,
matchType: formValue.matchType,
pattern: formValue.pattern,
target: formValue.target,
enabled: formValue.enabled,
};
const operation = existing
? this.exceptionService.updateException(existing.id, payload)
? this.exceptionService.updateException(this.tenantId, existing.id, payload)
: this.exceptionService.createException(this.tenantId, payload);
operation.subscribe({
@@ -464,7 +465,7 @@ export class ExceptionManagerComponent implements OnInit {
}
toggleEnabled(exception: SecretExceptionPattern): void {
this.exceptionService.updateException(exception.id, {
this.exceptionService.updateException(this.tenantId, exception.id, {
enabled: !exception.enabled,
}).subscribe({
next: () => {
@@ -477,7 +478,7 @@ export class ExceptionManagerComponent implements OnInit {
deleteException(exception: SecretExceptionPattern): void {
if (!confirm(`Delete exception "${exception.name}"?`)) return;
this.exceptionService.deleteException(exception.id).subscribe({
this.exceptionService.deleteException(this.tenantId, exception.id).subscribe({
next: () => {
this.showSuccess('Exception deleted');
this.loadExceptions();

View File

@@ -121,6 +121,7 @@ export class MaskedValueDisplayComponent {
@Input() value = '';
@Input() findingId = '';
@Input() tenantId = '';
@Input() canReveal = false;
@Output() revealed = new EventEmitter<string>();
@@ -138,12 +139,12 @@ export class MaskedValueDisplayComponent {
};
reveal(): void {
if (!this.canReveal || !this.findingId) return;
if (!this.canReveal || !this.findingId || !this.tenantId) return;
this.revealing.set(true);
this.findingsService.revealValue(this.findingId).subscribe({
next: (value) => {
this.findingsService.revealValue(this.tenantId, this.findingId).subscribe({
next: (value: string) => {
this.revealedValue.set(value);
this.isRevealed.set(true);
this.revealing.set(false);

View File

@@ -26,8 +26,8 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { MatMenuModule } from '@angular/material/menu';
import { SecretFindingsService } from '../../services';
import { SecretFinding } from '../../models';
import { MaskedValueDisplayComponent } from './masked-value-display.component';
import { SecretFinding, SecretSeverity, SecretFindingStatus } from '../../models';
import { MaskedValueDisplayComponent } from './';
@Component({
selector: 'app-secret-findings-list',
@@ -426,7 +426,9 @@ export class SecretFindingsListComponent implements OnInit {
// Expose service signals
readonly findings = this.findingsService.findings;
readonly pagination = this.findingsService.pagination;
readonly page = this.findingsService.page;
readonly pageSize = this.findingsService.pageSize;
readonly total = this.findingsService.total;
readonly loading = this.findingsService.loading;
readonly error = this.findingsService.error;
@@ -459,14 +461,11 @@ export class SecretFindingsListComponent implements OnInit {
reload(): void {
const tid = this.tenantId();
if (tid) {
this.findingsService.loadFindings(tid, {
this.findingsService.listFindings(tid, {
page: 1,
pageSize: 25,
search: this.searchQuery(),
severity: this.severityFilter(),
status: this.statusFilter(),
sortBy: this.sortField(),
sortDirection: this.sortDirection(),
severity: this.severityFilter() as SecretSeverity[],
status: this.statusFilter() ? [this.statusFilter() as SecretFindingStatus] : undefined,
}).subscribe();
}
}
@@ -496,14 +495,11 @@ export class SecretFindingsListComponent implements OnInit {
onPageChange(event: PageEvent): void {
const tid = this.tenantId();
if (tid) {
this.findingsService.loadFindings(tid, {
this.findingsService.listFindings(tid, {
page: event.pageIndex + 1,
pageSize: event.pageSize,
search: this.searchQuery(),
severity: this.severityFilter(),
status: this.statusFilter(),
sortBy: this.sortField(),
sortDirection: this.sortDirection(),
severity: this.severityFilter() as SecretSeverity[],
status: this.statusFilter() ? [this.statusFilter() as SecretFindingStatus] : undefined,
}).subscribe();
}
}
@@ -527,7 +523,8 @@ export class SecretFindingsListComponent implements OnInit {
}
markResolved(finding: SecretFinding): void {
this.findingsService.updateStatus(finding.id, 'resolved').subscribe({
const tid = this.tenantId();
this.findingsService.updateStatus(tid, finding.id, 'Resolved').subscribe({
next: () => this.reload(),
});
}

View File

@@ -260,7 +260,14 @@ type RevelationMode = 'masked' | 'partial' | 'full' | 'redacted';
`]
})
export class RevelationPolicySelectorComponent {
@Input() config: RevelationPolicyConfig = { mode: 'masked' };
@Input() config: RevelationPolicyConfig = {
defaultPolicy: 'FullMask',
exportPolicy: 'FullMask',
logPolicy: 'FullMask',
fullRevealRoles: [],
partialRevealChars: 4,
mode: 'masked',
};
@Output() configChange = new EventEmitter<RevelationPolicyConfig>();
private readonly sampleSecret = 'ghp_abc123XYZ789secret';

View File

@@ -19,10 +19,10 @@ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { ActivatedRoute } from '@angular/router';
import { SecretDetectionSettingsService } from '../../services';
import { RevelationPolicySelectorComponent } from '../settings/revelation-policy-selector.component';
import { RuleCategoryTogglesComponent } from '../settings/rule-category-toggles.component';
import { ExceptionManagerComponent } from '../exceptions/exception-manager.component';
import { AlertDestinationConfigComponent } from '../alerts/alert-destination-config.component';
import { RevelationPolicySelectorComponent } from './revelation-policy-selector.component';
import { RuleCategoryTogglesComponent } from './rule-category-toggles.component';
import { ExceptionManagerComponent } from '../exceptions';
import { AlertDestinationConfigComponent } from '../alerts';
import { RevelationPolicyConfig, SecretAlertSettings } from '../../models';
@Component({

View File

@@ -24,6 +24,11 @@ export interface SecretDetectionSettings {
*/
export type SecretRevelationPolicy = 'FullMask' | 'PartialReveal' | 'FullReveal';
/**
* Revelation mode for display settings.
*/
export type RevelationMode = 'masked' | 'partial' | 'full' | 'redacted';
/**
* Configuration for revelation policies by context.
*/
@@ -33,6 +38,18 @@ export interface RevelationPolicyConfig {
logPolicy: SecretRevelationPolicy;
fullRevealRoles: string[];
partialRevealChars: number;
/** Display mode for UI presentation */
mode?: RevelationMode;
/** Character used for masking */
maskChar?: string;
/** Length of the mask */
maskLength?: number;
/** Number of characters to reveal at start */
revealFirst?: number;
/** Number of characters to reveal at end */
revealLast?: number;
/** Required permission for full reveal */
requiredPermission?: string;
}
/**
@@ -47,6 +64,16 @@ export interface SecretExceptionPattern {
expiresAt?: string;
ruleIds?: string[];
pathFilter?: string;
/** Display name for the exception */
name?: string;
/** Description of why exception exists */
description?: string;
/** How the pattern is matched */
matchType?: 'literal' | 'regex' | 'glob' | 'prefix' | 'suffix';
/** What the pattern applies to */
target?: 'value' | 'path' | 'filename';
/** Whether the exception is active */
enabled?: boolean;
}
/**
@@ -62,6 +89,12 @@ export interface SecretAlertSettings {
includeMaskedValue: boolean;
includeImageRef: boolean;
alertMessagePrefix?: string;
/** Minimum severity to trigger alert (UI variant) */
minimumSeverity?: SecretSeverity;
/** Rate limit per hour */
rateLimitPerHour?: number;
/** Deduplication window in minutes (UI variant) */
deduplicationWindowMinutes?: number;
}
/**
@@ -74,6 +107,12 @@ export interface SecretAlertDestination {
channelId: string;
severityFilter?: SecretSeverity[];
ruleCategoryFilter?: string[];
/** Destination type (UI variant) */
type?: 'webhook' | 'slack' | 'email' | 'teams' | 'pagerduty';
/** Configuration options */
config?: Record<string, unknown>;
/** Whether destination is enabled */
enabled?: boolean;
}
/**
@@ -120,6 +159,8 @@ export interface SecretRuleCategory {
description: string;
ruleCount: number;
enabled: boolean;
/** Group for categorization */
group?: string;
}
/**
@@ -131,6 +172,16 @@ export interface CreateExceptionRequest {
expiresAt?: string;
ruleIds?: string[];
pathFilter?: string;
/** Display name for the exception */
name?: string;
/** Description of why exception exists */
description?: string;
/** How the pattern is matched */
matchType?: 'literal' | 'regex' | 'glob' | 'prefix' | 'suffix';
/** What the pattern applies to */
target?: 'value' | 'path' | 'filename';
/** Whether the exception is active */
enabled?: boolean;
}
/**

View File

@@ -7,7 +7,7 @@
import { Injectable, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { catchError, tap } from 'rxjs/operators';
import { catchError, tap, map } from 'rxjs/operators';
import { Observable, of } from 'rxjs';
import {
SecretExceptionPattern,
@@ -47,12 +47,10 @@ export class SecretExceptionService {
catchError(err => {
this._error.set(err.message || 'Failed to load exceptions');
this._loading.set(false);
return of({ items: [] });
return of({ items: [] as SecretExceptionPattern[] });
}),
// Transform response
tap(() => {}),
// Return just the items array
tap(response => this._exceptions.set(response.items))
// Map to array
map(response => response.items)
);
}

View File

@@ -7,7 +7,7 @@
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { catchError, tap } from 'rxjs/operators';
import { catchError, tap, map } from 'rxjs/operators';
import { Observable, of } from 'rxjs';
import {
SecretFinding,
@@ -157,6 +157,21 @@ export class SecretFindingsService {
);
}
/**
* Reveals the secret value for a finding (requires appropriate permissions).
*/
revealValue(tenantId: string, findingId: string): Observable<string> {
return this.http.get<{ value: string }>(
`${this.baseUrl}/${tenantId}/${findingId}/reveal`
).pipe(
map(response => response.value),
catchError(err => {
this._error.set(err.message || 'Failed to reveal secret value');
throw err;
})
);
}
/**
* Selects a finding for detail view.
*/

View File

@@ -249,7 +249,7 @@ public sealed class RuntimeAdmissionPolicyServiceTests
new[] { "admission" },
new SurfaceSecretsConfiguration("inline", "default", null, null, null, true),
"default",
new SurfaceTlsConfiguration(null, null, null));
new SurfaceTlsConfiguration(null, null, null)) { CreatedAtUtc = DateTimeOffset.UtcNow };
private sealed class StubSurfaceFsClient : IWebhookSurfaceFsClient
{

View File

@@ -11,6 +11,7 @@ namespace StellaOps.Cryptography.Kms;
public sealed class AwsKmsClient : IKmsClient, IDisposable
{
private readonly IAwsKmsFacade _facade;
private readonly TimeProvider _timeProvider;
private readonly TimeSpan _metadataCacheDuration;
private readonly TimeSpan _publicKeyCacheDuration;
@@ -18,11 +19,12 @@ public sealed class AwsKmsClient : IKmsClient, IDisposable
private readonly ConcurrentDictionary<string, CachedPublicKey> _publicKeyCache = new(StringComparer.Ordinal);
private bool _disposed;
public AwsKmsClient(IAwsKmsFacade facade, AwsKmsOptions options)
public AwsKmsClient(IAwsKmsFacade facade, AwsKmsOptions options, TimeProvider? timeProvider = null)
{
_facade = facade ?? throw new ArgumentNullException(nameof(facade));
ArgumentNullException.ThrowIfNull(options);
_timeProvider = timeProvider ?? TimeProvider.System;
_metadataCacheDuration = options.MetadataCacheDuration;
_publicKeyCacheDuration = options.PublicKeyCacheDuration;
}
@@ -156,7 +158,7 @@ public sealed class AwsKmsClient : IKmsClient, IDisposable
private async Task<AwsKeyMetadata> GetCachedMetadataAsync(string keyId, CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
if (_metadataCache.TryGetValue(keyId, out var cached) && cached.ExpiresAt > now)
{
return cached.Metadata;
@@ -170,7 +172,7 @@ public sealed class AwsKmsClient : IKmsClient, IDisposable
private async Task<AwsPublicKeyMaterial> GetCachedPublicKeyAsync(string resource, CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
if (_publicKeyCache.TryGetValue(resource, out var cached) && cached.ExpiresAt > now)
{
return cached.Material;

Some files were not shown because too many files have changed in this diff Show More