using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http.Json; using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using StellaOps.Policy; using StellaOps.Scanner.Storage.Catalog; using StellaOps.Scanner.Storage.Repositories; using StellaOps.Scanner.WebService.Contracts; using StellaOps.Zastava.Core.Contracts; namespace StellaOps.Scanner.WebService.Tests; public sealed class RuntimeEndpointsTests { [Fact] public async Task RuntimeEventsEndpointPersistsEvents() { using var factory = new ScannerApplicationFactory(); using var client = factory.CreateClient(); var request = new RuntimeEventsIngestRequestDto { BatchId = "batch-1", Events = new[] { CreateEnvelope("evt-001", buildId: "ABCDEF1234567890ABCDEF1234567890ABCDEF12"), CreateEnvelope("evt-002", buildId: "abcdef1234567890abcdef1234567890abcdef12") } }; var response = await client.PostAsJsonAsync("/api/v1/runtime/events", request); Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); var payload = await response.Content.ReadFromJsonAsync(); Assert.NotNull(payload); Assert.Equal(2, payload!.Accepted); Assert.Equal(0, payload.Duplicates); using var scope = factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var stored = await repository.ListAsync(CancellationToken.None); Assert.Equal(2, stored.Count); Assert.Contains(stored, doc => doc.EventId == "evt-001"); Assert.All(stored, doc => { Assert.Equal("tenant-alpha", doc.Tenant); Assert.True(doc.ExpiresAt > doc.ReceivedAt); Assert.Equal("sha256:deadbeef", doc.ImageDigest); Assert.Equal("abcdef1234567890abcdef1234567890abcdef12", doc.BuildId); }); } [Fact] public async Task RuntimeEventsEndpointRejectsUnsupportedSchema() { using var factory = new ScannerApplicationFactory(); using var client = factory.CreateClient(); var envelope = CreateEnvelope("evt-100", schemaVersion: "zastava.runtime.event@v2.0"); var request = new RuntimeEventsIngestRequestDto { Events = new[] { envelope } }; var response = await client.PostAsJsonAsync("/api/v1/runtime/events", request); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } [Fact] public async Task RuntimeEventsEndpointEnforcesRateLimit() { using var factory = new ScannerApplicationFactory(configuration => { configuration["scanner:runtime:perNodeBurst"] = "1"; configuration["scanner:runtime:perNodeEventsPerSecond"] = "1"; configuration["scanner:runtime:perTenantBurst"] = "1"; configuration["scanner:runtime:perTenantEventsPerSecond"] = "1"; }); using var client = factory.CreateClient(); var request = new RuntimeEventsIngestRequestDto { Events = new[] { CreateEnvelope("evt-500"), CreateEnvelope("evt-501") } }; var response = await client.PostAsJsonAsync("/api/v1/runtime/events", request); Assert.Equal((HttpStatusCode)StatusCodes.Status429TooManyRequests, response.StatusCode); Assert.NotNull(response.Headers.RetryAfter); using var scope = factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var count = await repository.CountAsync(CancellationToken.None); Assert.Equal(0, count); } [Fact] public async Task RuntimePolicyEndpointReturnsDecisions() { using var factory = new ScannerApplicationFactory(configuration => { configuration["scanner:runtime:policyCacheTtlSeconds"] = "600"; }); const string imageDigest = "sha256:deadbeef"; using var client = factory.CreateClient(); using (var scope = factory.Services.CreateScope()) { var artifacts = scope.ServiceProvider.GetRequiredService(); var links = scope.ServiceProvider.GetRequiredService(); var policyStore = scope.ServiceProvider.GetRequiredService(); var runtimeRepository = scope.ServiceProvider.GetRequiredService(); await runtimeRepository.TruncateAsync(CancellationToken.None); const string policyYaml = """ version: "1.0" rules: - name: Block Critical severity: [Critical] action: block """; var saveResult = await policyStore.SaveAsync( new PolicySnapshotContent(policyYaml, PolicyDocumentFormat.Yaml, "tester", "tests", "seed"), CancellationToken.None); Assert.True(saveResult.Success); var snapshot = await policyStore.GetLatestAsync(CancellationToken.None); Assert.NotNull(snapshot); var sbomArtifactId = CatalogIdFactory.CreateArtifactId(ArtifactDocumentType.ImageBom, "sha256:sbomdigest"); var attestationArtifactId = CatalogIdFactory.CreateArtifactId(ArtifactDocumentType.Attestation, "sha256:attdigest"); await artifacts.UpsertAsync(new ArtifactDocument { Id = sbomArtifactId, Type = ArtifactDocumentType.ImageBom, Format = ArtifactDocumentFormat.CycloneDxJson, MediaType = "application/json", BytesSha256 = "sha256:sbomdigest", RefCount = 1 }, CancellationToken.None); await artifacts.UpsertAsync(new ArtifactDocument { Id = attestationArtifactId, Type = ArtifactDocumentType.Attestation, Format = ArtifactDocumentFormat.DsseJson, MediaType = "application/vnd.dsse.envelope+json", BytesSha256 = "sha256:attdigest", RefCount = 1, Rekor = new RekorReference { Uuid = "rekor-uuid", Url = "https://rekor.example/uuid/rekor-uuid", Index = 7 } }, CancellationToken.None); await links.UpsertAsync(new LinkDocument { Id = Guid.NewGuid().ToString("N"), FromType = LinkSourceType.Image, FromDigest = imageDigest, ArtifactId = sbomArtifactId, CreatedAtUtc = DateTime.UtcNow }, CancellationToken.None); await links.UpsertAsync(new LinkDocument { Id = Guid.NewGuid().ToString("N"), FromType = LinkSourceType.Image, FromDigest = imageDigest, ArtifactId = attestationArtifactId, CreatedAtUtc = DateTime.UtcNow }, CancellationToken.None); } var ingestRequest = new RuntimeEventsIngestRequestDto { Events = new[] { CreateEnvelope("evt-210", imageDigest: imageDigest, buildId: "1122aabbccddeeff00112233445566778899aabb"), CreateEnvelope("evt-211", imageDigest: imageDigest, buildId: "1122AABBCCDDEEFF00112233445566778899AABB") } }; var ingestResponse = await client.PostAsJsonAsync("/api/v1/runtime/events", ingestRequest); Assert.Equal(HttpStatusCode.Accepted, ingestResponse.StatusCode); var request = new RuntimePolicyRequestDto { Namespace = "payments", Images = new[] { imageDigest, imageDigest }, Labels = new Dictionary { ["app"] = "api" } }; var response = await client.PostAsJsonAsync("/api/v1/policy/runtime", request); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var raw = await response.Content.ReadAsStringAsync(); Assert.False(string.IsNullOrWhiteSpace(raw), "Runtime policy response body was empty."); var payload = JsonSerializer.Deserialize(raw); Assert.True(payload is not null, $"Runtime policy response: {raw}"); Assert.Equal(600, payload!.TtlSeconds); Assert.NotNull(payload.PolicyRevision); Assert.True(payload.ExpiresAtUtc > DateTimeOffset.UtcNow); var decision = payload.Results[imageDigest]; Assert.Equal("pass", decision.PolicyVerdict); Assert.True(decision.Signed); Assert.True(decision.HasSbomReferrers); Assert.True(decision.HasSbomLegacy); Assert.Empty(decision.Reasons); Assert.NotNull(decision.Rekor); Assert.Equal("rekor-uuid", decision.Rekor!.Uuid); Assert.True(decision.Rekor.Verified); Assert.NotNull(decision.Confidence); Assert.InRange(decision.Confidence!.Value, 0.0, 1.0); Assert.False(decision.Quieted.GetValueOrDefault()); Assert.Null(decision.QuietedBy); Assert.NotNull(decision.BuildIds); Assert.Contains("1122aabbccddeeff00112233445566778899aabb", decision.BuildIds!); var metadataString = decision.Metadata; Console.WriteLine($"Runtime policy metadata: {metadataString ?? ""}"); Assert.False(string.IsNullOrWhiteSpace(metadataString)); using var metadataDocument = JsonDocument.Parse(decision.Metadata!); Assert.True(metadataDocument.RootElement.TryGetProperty("heuristics", out _)); } [Fact] public async Task RuntimePolicyEndpointFlagsUnsignedAndMissingSbom() { using var factory = new ScannerApplicationFactory(); using var client = factory.CreateClient(); const string imageDigest = "sha256:feedface"; using (var scope = factory.Services.CreateScope()) { var runtimeRepository = scope.ServiceProvider.GetRequiredService(); var policyStore = scope.ServiceProvider.GetRequiredService(); const string policyYaml = """ version: "1.0" rules: [] """; await policyStore.SaveAsync( new PolicySnapshotContent(policyYaml, PolicyDocumentFormat.Yaml, "tester", "tests", "baseline"), CancellationToken.None); // Intentionally skip artifacts/links to simulate missing metadata. await runtimeRepository.TruncateAsync(CancellationToken.None); } var response = await client.PostAsJsonAsync("/api/v1/policy/runtime", new RuntimePolicyRequestDto { Namespace = "payments", Images = new[] { imageDigest } }); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var payload = await response.Content.ReadFromJsonAsync(); Assert.NotNull(payload); var decision = payload!.Results[imageDigest]; Assert.Equal("fail", decision.PolicyVerdict); Assert.False(decision.Signed); Assert.False(decision.HasSbomReferrers); Assert.Contains("image.metadata.missing", decision.Reasons); Assert.Contains("unsigned", decision.Reasons); Assert.Contains("missing SBOM", decision.Reasons); Assert.NotNull(decision.Confidence); Assert.InRange(decision.Confidence!.Value, 0.0, 1.0); if (!string.IsNullOrWhiteSpace(decision.Metadata)) { using var failureMetadata = JsonDocument.Parse(decision.Metadata!); if (failureMetadata.RootElement.TryGetProperty("heuristics", out var heuristicsElement)) { var heuristics = heuristicsElement.EnumerateArray().Select(item => item.GetString()).ToArray(); Assert.Contains("image.metadata.missing", heuristics); Assert.Contains("unsigned", heuristics); } } } [Fact] public async Task RuntimePolicyEndpointValidatesRequest() { using var factory = new ScannerApplicationFactory(); using var client = factory.CreateClient(); var request = new RuntimePolicyRequestDto { Images = Array.Empty() }; var response = await client.PostAsJsonAsync("/api/v1/policy/runtime", request); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } private static RuntimeEventEnvelope CreateEnvelope( string eventId, string? schemaVersion = null, string? imageDigest = null, string? buildId = null) { var digest = string.IsNullOrWhiteSpace(imageDigest) ? "sha256:deadbeef" : imageDigest; var runtimeEvent = new RuntimeEvent { EventId = eventId, When = DateTimeOffset.UtcNow, Kind = RuntimeEventKind.ContainerStart, Tenant = "tenant-alpha", Node = "node-a", Runtime = new RuntimeEngine { Engine = "containerd", Version = "1.7.0" }, Workload = new RuntimeWorkload { Platform = "kubernetes", Namespace = "default", Pod = "api-123", Container = "api", ContainerId = "containerd://abc", ImageRef = $"ghcr.io/example/api@{digest}" }, Delta = new RuntimeDelta { BaselineImageDigest = digest }, Process = new RuntimeProcess { Pid = 123, Entrypoint = new[] { "/bin/start" }, EntryTrace = Array.Empty(), BuildId = buildId } }; if (schemaVersion is null) { return RuntimeEventEnvelope.Create(runtimeEvent, ZastavaContractVersions.RuntimeEvent); } return new RuntimeEventEnvelope { SchemaVersion = schemaVersion, Event = runtimeEvent }; } }