This commit is contained in:
		| @@ -0,0 +1,191 @@ | ||||
| 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<string, RuntimePolicyImageResult>(StringComparer.Ordinal) | ||||
|                 { | ||||
|                     [image] = result | ||||
|                 } | ||||
|             }; | ||||
|         }); | ||||
|  | ||||
|         var evaluator = new RuntimePostureEvaluator(client, cache, options, timeProvider, NullLogger<RuntimePostureEvaluator>.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<RuntimePostureEvaluator>.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<string, string>(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<string, string>(StringComparer.Ordinal), | ||||
|             CreatedAt: DateTimeOffset.UtcNow, | ||||
|             StartedAt: DateTimeOffset.UtcNow, | ||||
|             FinishedAt: null, | ||||
|             ExitCode: null, | ||||
|             Reason: null, | ||||
|             Message: null, | ||||
|             Pid: 1234); | ||||
|     } | ||||
|  | ||||
|     private static TestOptionsMonitor<ZastavaObserverOptions> 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<ZastavaObserverOptions>(options); | ||||
|     } | ||||
|  | ||||
|     private sealed class StubPolicyClient : IRuntimePolicyClient | ||||
|     { | ||||
|         private readonly Func<string, RuntimePolicyResponse> factory; | ||||
|  | ||||
|         public StubPolicyClient(Func<string, RuntimePolicyResponse> factory) | ||||
|         { | ||||
|             this.factory = factory; | ||||
|         } | ||||
|  | ||||
|         public Task<RuntimePolicyResponse> EvaluateAsync(RuntimePolicyRequest request, CancellationToken cancellationToken = default) | ||||
|         { | ||||
|             var image = request.Images.First(); | ||||
|             return Task.FromResult(factory(image)); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private sealed class StubPostureCache : IRuntimePostureCache | ||||
|     { | ||||
|         private readonly Dictionary<string, RuntimePostureCacheEntry> 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<T> : IOptionsMonitor<T> | ||||
|     { | ||||
|         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<T, string?> listener) => NullDisposable.Instance; | ||||
|  | ||||
|         private sealed class NullDisposable : IDisposable | ||||
|         { | ||||
|             public static readonly NullDisposable Instance = new(); | ||||
|             public void Dispose() | ||||
|             { | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user