using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; using System.Threading; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Microsoft.Extensions.Time.Testing; using StellaOps.Zastava.Core.Contracts; using StellaOps.Zastava.Observer.Backend; using StellaOps.Zastava.Observer.Configuration; using StellaOps.Zastava.Observer.ContainerRuntime.Cri; using StellaOps.Zastava.Observer.Posture; using Xunit; namespace StellaOps.Zastava.Observer.Tests.Posture; public sealed class RuntimePostureEvaluatorTests { [Fact] public async Task EvaluateAsync_BacksOffToBackendAndCachesEntry() { var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 24, 12, 0, 0, TimeSpan.Zero)); var cache = new StubPostureCache(); var options = CreateOptions(); var client = new StubPolicyClient(image => { var result = new RuntimePolicyImageResult { Signed = true, HasSbomReferrers = true, Rekor = new RuntimePolicyRekorResult { Uuid = "rekor-123", Verified = true } }; return new RuntimePolicyResponse { TtlSeconds = 600, ExpiresAtUtc = timeProvider.GetUtcNow().AddMinutes(10), Results = new Dictionary(StringComparer.Ordinal) { [image] = result } }; }); var evaluator = new RuntimePostureEvaluator(client, cache, options, timeProvider, NullLogger.Instance); var container = CreateContainerInfo(); var evaluation = await evaluator.EvaluateAsync(container, CancellationToken.None); Assert.NotNull(evaluation.Posture); Assert.True(evaluation.Posture!.ImageSigned); Assert.Equal("present", evaluation.Posture.SbomReferrer); Assert.Contains(evaluation.Evidence, e => e.Signal == "runtime.posture.source" && e.Value == "backend"); var cached = cache.Get(container.ImageRef!); Assert.NotNull(cached); } [Fact] public async Task EvaluateAsync_UsesCacheWhenBackendFails() { var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 24, 12, 0, 0, TimeSpan.Zero)); var cache = new StubPostureCache(); var options = CreateOptions(); var imageRef = "ghcr.io/example/app@sha256:deadbeef"; var cachedPosture = new RuntimePosture { ImageSigned = false, SbomReferrer = "missing" }; cache.Seed(imageRef, cachedPosture, timeProvider.GetUtcNow().AddMinutes(-1), timeProvider.GetUtcNow().AddMinutes(-10)); var client = new StubPolicyClient(_ => throw new InvalidOperationException("backend unavailable")); var evaluator = new RuntimePostureEvaluator(client, cache, options, timeProvider, NullLogger.Instance); var container = CreateContainerInfo(imageRef); var evaluation = await evaluator.EvaluateAsync(container, CancellationToken.None); Assert.NotNull(evaluation.Posture); Assert.False(evaluation.Posture!.ImageSigned); Assert.Contains(evaluation.Evidence, e => e.Signal == "runtime.posture.cache"); Assert.Contains(evaluation.Evidence, e => e.Signal == "runtime.posture.error"); } private static CriContainerInfo CreateContainerInfo(string? imageRef = null) { var labels = new Dictionary(StringComparer.Ordinal) { [CriLabelKeys.PodNamespace] = "payments", [CriLabelKeys.PodName] = "api-pod", [CriLabelKeys.ContainerName] = "api" }; return new CriContainerInfo( Id: "container-a", PodSandboxId: "sandbox-a", Name: "api", Attempt: 1, Image: "ghcr.io/example/app:1.0.0", ImageRef: imageRef ?? "ghcr.io/example/app@sha256:deadbeef", Labels: labels, Annotations: new Dictionary(StringComparer.Ordinal), CreatedAt: DateTimeOffset.UtcNow, StartedAt: DateTimeOffset.UtcNow, FinishedAt: null, ExitCode: null, Reason: null, Message: null, Pid: 1234); } private static TestOptionsMonitor CreateOptions() { var options = new ZastavaObserverOptions { Posture = new ZastavaObserverPostureOptions { CachePath = Path.Combine(Path.GetTempPath(), "zastava-observer-tests", Guid.NewGuid().ToString("N"), "posture-cache.json"), FallbackTtlSeconds = 300, StaleWarningThresholdSeconds = 600 } }; return new TestOptionsMonitor(options); } private sealed class StubPolicyClient : IRuntimePolicyClient { private readonly Func factory; public StubPolicyClient(Func factory) { this.factory = factory; } public Task EvaluateAsync(RuntimePolicyRequest request, CancellationToken cancellationToken = default) { var image = request.Images.First(); return Task.FromResult(factory(image)); } } private sealed class StubPostureCache : IRuntimePostureCache { private readonly Dictionary entries = new(StringComparer.Ordinal); public RuntimePostureCacheEntry? Get(string key) { entries.TryGetValue(key, out var entry); return entry; } public void Seed(string key, RuntimePosture posture, DateTimeOffset expiresAt, DateTimeOffset storedAt) { entries[key] = new RuntimePostureCacheEntry(posture, expiresAt, storedAt); } public void Set(string key, RuntimePosture posture, DateTimeOffset expiresAtUtc, DateTimeOffset storedAtUtc) { entries[key] = new RuntimePostureCacheEntry(posture, expiresAtUtc, storedAtUtc); } } private sealed class TestOptionsMonitor : IOptionsMonitor { private readonly T value; public TestOptionsMonitor(T value) { this.value = value; } public T CurrentValue => value; public T Get(string? name) => value; public IDisposable OnChange(Action listener) => NullDisposable.Instance; private sealed class NullDisposable : IDisposable { public static readonly NullDisposable Instance = new(); public void Dispose() { } } } }