using System; using System.Collections.Generic; using System.Collections.Immutable; using System.IO; using System.Net; using System.Net.Http.Json; using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; using System.Threading; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using StellaOps.Scanner.EntryTrace; using StellaOps.Scanner.EntryTrace.Serialization; using StellaOps.Scanner.Storage.Catalog; using StellaOps.Scanner.Storage.Repositories; using StellaOps.Scanner.WebService.Contracts; using StellaOps.Scanner.WebService.Domain; using StellaOps.Scanner.WebService.Services; namespace StellaOps.Scanner.WebService.Tests; public sealed class ScansEndpointsTests { [Fact] public async Task SubmitScanReturnsAcceptedAndStatusRetrievable() { using var factory = new ScannerApplicationFactory(); using var client = factory.CreateClient(); var request = new ScanSubmitRequest { Image = new ScanImageDescriptor { Reference = "ghcr.io/demo/app:1.0.0" }, Force = false }; var response = await client.PostAsJsonAsync("/api/v1/scans", request); Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); var payload = await response.Content.ReadFromJsonAsync(); Assert.NotNull(payload); Assert.False(string.IsNullOrWhiteSpace(payload!.ScanId)); Assert.Equal("Pending", payload.Status); Assert.True(payload.Created); Assert.False(string.IsNullOrWhiteSpace(payload.Location)); var statusResponse = await client.GetAsync(payload.Location); Assert.Equal(HttpStatusCode.OK, statusResponse.StatusCode); var status = await statusResponse.Content.ReadFromJsonAsync(); Assert.NotNull(status); Assert.Equal(payload.ScanId, status!.ScanId); Assert.Equal("Pending", status.Status); Assert.Equal("ghcr.io/demo/app:1.0.0", status.Image.Reference); } [Fact] public async Task SubmitScanIsDeterministicForIdenticalPayloads() { using var factory = new ScannerApplicationFactory(); using var client = factory.CreateClient(); var request = new ScanSubmitRequest { Image = new ScanImageDescriptor { Reference = "registry.example.com/acme/app:latest" }, Force = false, ClientRequestId = "client-123", Metadata = new Dictionary { ["origin"] = "unit-test" } }; var first = await client.PostAsJsonAsync("/api/v1/scans", request); var firstPayload = await first.Content.ReadFromJsonAsync(); var second = await client.PostAsJsonAsync("/api/v1/scans", request); var secondPayload = await second.Content.ReadFromJsonAsync(); Assert.NotNull(firstPayload); Assert.NotNull(secondPayload); Assert.Equal(firstPayload!.ScanId, secondPayload!.ScanId); Assert.True(firstPayload.Created); Assert.False(secondPayload.Created); } [Fact] public async Task ScanStatusIncludesSurfacePointersWhenArtifactsExist() { const string digest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; var digestValue = digest.Split(':', 2)[1]; using var factory = new ScannerApplicationFactory(); using (var scope = factory.Services.CreateScope()) { var artifactRepository = scope.ServiceProvider.GetRequiredService(); var linkRepository = scope.ServiceProvider.GetRequiredService(); var artifactId = CatalogIdFactory.CreateArtifactId(ArtifactDocumentType.ImageBom, digest); var artifact = new ArtifactDocument { Id = artifactId, Type = ArtifactDocumentType.ImageBom, Format = ArtifactDocumentFormat.CycloneDxJson, MediaType = "application/vnd.cyclonedx+json; version=1.6; view=inventory", BytesSha256 = digest, SizeBytes = 2048, Immutable = true, RefCount = 1, TtlClass = "default", CreatedAtUtc = DateTime.UtcNow, UpdatedAtUtc = DateTime.UtcNow }; await artifactRepository.UpsertAsync(artifact, CancellationToken.None).ConfigureAwait(false); var link = new LinkDocument { Id = CatalogIdFactory.CreateLinkId(LinkSourceType.Image, digest, artifactId), FromType = LinkSourceType.Image, FromDigest = digest, ArtifactId = artifactId, CreatedAtUtc = DateTime.UtcNow }; await linkRepository.UpsertAsync(link, CancellationToken.None).ConfigureAwait(false); } using var client = factory.CreateClient(); var submitRequest = new ScanSubmitRequest { Image = new ScanImageDescriptor { Digest = digest } }; var submitResponse = await client.PostAsJsonAsync("/api/v1/scans", submitRequest); submitResponse.EnsureSuccessStatusCode(); var submission = await submitResponse.Content.ReadFromJsonAsync(); Assert.NotNull(submission); var statusResponse = await client.GetAsync($"/api/v1/scans/{submission!.ScanId}"); statusResponse.EnsureSuccessStatusCode(); var status = await statusResponse.Content.ReadFromJsonAsync(); Assert.NotNull(status); Assert.NotNull(status!.Surface); var surface = status.Surface!; Assert.Equal("default", surface.Tenant); Assert.False(string.IsNullOrWhiteSpace(surface.ManifestDigest)); Assert.NotNull(surface.ManifestUri); Assert.Contains("cas://scanner-artifacts/", surface.ManifestUri, StringComparison.Ordinal); var manifest = surface.Manifest; Assert.Equal(digest, manifest.ImageDigest); Assert.Equal(surface.Tenant, manifest.Tenant); Assert.NotEqual(default, manifest.GeneratedAt); var manifestArtifact = Assert.Single(manifest.Artifacts); Assert.Equal("sbom-inventory", manifestArtifact.Kind); Assert.Equal("cdx-json", manifestArtifact.Format); Assert.Equal(digest, manifestArtifact.Digest); Assert.Equal("application/vnd.cyclonedx+json; version=1.6; view=inventory", manifestArtifact.MediaType); Assert.Equal("inventory", manifestArtifact.View); var expectedUri = $"cas://scanner-artifacts/scanner/images/{digestValue}/sbom.cdx.json"; Assert.Equal(expectedUri, manifestArtifact.Uri); } [Fact] public async Task SubmitScanValidatesImageDescriptor() { using var factory = new ScannerApplicationFactory(); using var client = factory.CreateClient(); var request = new { image = new { reference = "", digest = "" } }; var response = await client.PostAsJsonAsync("/api/v1/scans", request); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } [Fact] public async Task SubmitScanPropagatesRequestAbortedToken() { RecordingCoordinator coordinator = null!; using var factory = new ScannerApplicationFactory(configuration => { configuration["scanner:authority:enabled"] = "false"; }, services => { services.AddSingleton(sp => { coordinator = new RecordingCoordinator( sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService()); return coordinator; }); }); using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); var cts = new CancellationTokenSource(); var request = new ScanSubmitRequest { Image = new ScanImageDescriptor { Reference = "example.com/demo:1.0" } }; var response = await client.PostAsJsonAsync("/api/v1/scans", request, cts.Token); Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); Assert.NotNull(coordinator); Assert.True(coordinator.TokenMatched); Assert.True(coordinator.LastToken.CanBeCanceled); } [Fact] public async Task EntryTraceEndpointReturnsStoredResult() { using var factory = new ScannerApplicationFactory(); var scanId = $"scan-entrytrace-{Guid.NewGuid():n}"; var graph = new EntryTraceGraph( EntryTraceOutcome.Resolved, ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Create(new EntryTracePlan( ImmutableArray.Create("/bin/bash", "-lc", "./start.sh"), ImmutableDictionary.Empty, "/workspace", "root", "/bin/bash", EntryTraceTerminalType.Script, "bash", 0.9, ImmutableDictionary.Empty)), ImmutableArray.Create(new EntryTraceTerminal( "/bin/bash", EntryTraceTerminalType.Script, "bash", 0.9, ImmutableDictionary.Empty, "root", "/workspace", ImmutableArray.Empty))); var ndjson = new List { "{\"kind\":\"entry\"}" }; using (var scope = factory.Services.CreateScope()) { var repository = scope.ServiceProvider.GetRequiredService(); await repository.UpsertAsync(new EntryTraceDocument { ScanId = scanId, ImageDigest = "sha256:entrytrace", GeneratedAtUtc = DateTime.UtcNow, GraphJson = EntryTraceGraphSerializer.Serialize(graph), Ndjson = ndjson }, CancellationToken.None).ConfigureAwait(false); } using var client = factory.CreateClient(); var response = await client.GetAsync($"/api/v1/scans/{scanId}/entrytrace"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var payload = await response.Content.ReadFromJsonAsync(SerializerOptions, CancellationToken.None); Assert.NotNull(payload); Assert.Equal(scanId, payload!.ScanId); Assert.Equal("sha256:entrytrace", payload.ImageDigest); Assert.Equal(graph.Outcome, payload.Graph.Outcome); Assert.Single(payload.Graph.Plans); Assert.Equal("/bin/bash", payload.Graph.Plans[0].TerminalPath); Assert.Single(payload.Graph.Terminals); Assert.Equal(ndjson, payload.Ndjson); } private sealed class RecordingCoordinator : IScanCoordinator { private readonly IHttpContextAccessor accessor; private readonly InMemoryScanCoordinator inner; public RecordingCoordinator(IHttpContextAccessor accessor, TimeProvider timeProvider, IScanProgressPublisher publisher) { this.accessor = accessor; inner = new InMemoryScanCoordinator(timeProvider, publisher); } public CancellationToken LastToken { get; private set; } public bool TokenMatched { get; private set; } public async ValueTask SubmitAsync(ScanSubmission submission, CancellationToken cancellationToken) { LastToken = cancellationToken; TokenMatched = accessor.HttpContext?.RequestAborted.Equals(cancellationToken) ?? false; return await inner.SubmitAsync(submission, cancellationToken); } public ValueTask GetAsync(ScanId scanId, CancellationToken cancellationToken) => inner.GetAsync(scanId, cancellationToken); } [Fact] public async Task ProgressStreamReturnsInitialPendingEvent() { using var factory = new ScannerApplicationFactory(); using var client = factory.CreateClient(); var request = new ScanSubmitRequest { Image = new ScanImageDescriptor { Reference = "ghcr.io/demo/app:2.0.0" } }; var submit = await client.PostAsJsonAsync("/api/v1/scans", request); var submitPayload = await submit.Content.ReadFromJsonAsync(); Assert.NotNull(submitPayload); var response = await client.GetAsync($"/api/v1/scans/{submitPayload!.ScanId}/events?format=jsonl", HttpCompletionOption.ResponseHeadersRead); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("application/x-ndjson", response.Content.Headers.ContentType?.MediaType); await using var stream = await response.Content.ReadAsStreamAsync(); using var reader = new StreamReader(stream); var line = await reader.ReadLineAsync(); Assert.False(string.IsNullOrWhiteSpace(line)); var envelope = JsonSerializer.Deserialize(line!, SerializerOptions); Assert.NotNull(envelope); Assert.Equal(submitPayload.ScanId, envelope!.ScanId); Assert.Equal("Pending", envelope.State); Assert.Equal(1, envelope.Sequence); Assert.NotEqual(default, envelope.Timestamp); } [Fact] public async Task ProgressStreamYieldsSubsequentEvents() { using var factory = new ScannerApplicationFactory(); using var client = factory.CreateClient(); var request = new ScanSubmitRequest { Image = new ScanImageDescriptor { Reference = "registry.example.com/acme/app:stream" } }; var submit = await client.PostAsJsonAsync("/api/v1/scans", request); var submitPayload = await submit.Content.ReadFromJsonAsync(); Assert.NotNull(submitPayload); var publisher = factory.Services.GetRequiredService(); var response = await client.GetAsync($"/api/v1/scans/{submitPayload!.ScanId}/events?format=jsonl", HttpCompletionOption.ResponseHeadersRead); await using var stream = await response.Content.ReadAsStreamAsync(); using var reader = new StreamReader(stream); var firstLine = await reader.ReadLineAsync(); Assert.NotNull(firstLine); var firstEnvelope = JsonSerializer.Deserialize(firstLine!, SerializerOptions); Assert.NotNull(firstEnvelope); Assert.Equal("Pending", firstEnvelope!.State); _ = Task.Run(async () => { await Task.Delay(50); publisher.Publish(new ScanId(submitPayload.ScanId), "Running", "worker-started", new Dictionary { ["stage"] = "download" }); }); ProgressEnvelope? envelope = null; string? line; do { line = await reader.ReadLineAsync(); if (line is null) { break; } if (line.Length == 0) { continue; } envelope = JsonSerializer.Deserialize(line, SerializerOptions); } while (envelope is not null && envelope.State == "Pending"); Assert.NotNull(envelope); Assert.Equal("Running", envelope!.State); Assert.True(envelope.Sequence >= 2); Assert.Contains(envelope.Data.Keys, key => key == "stage"); } [Fact] public async Task ProgressStreamSupportsServerSentEvents() { using var factory = new ScannerApplicationFactory(); using var client = factory.CreateClient(); var request = new ScanSubmitRequest { Image = new ScanImageDescriptor { Reference = "ghcr.io/demo/app:3.0.0" } }; var submit = await client.PostAsJsonAsync("/api/v1/scans", request); var submitPayload = await submit.Content.ReadFromJsonAsync(); Assert.NotNull(submitPayload); var response = await client.GetAsync($"/api/v1/scans/{submitPayload!.ScanId}/events", HttpCompletionOption.ResponseHeadersRead); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("text/event-stream", response.Content.Headers.ContentType?.MediaType); await using var stream = await response.Content.ReadAsStreamAsync(); using var reader = new StreamReader(stream); var idLine = await reader.ReadLineAsync(); var eventLine = await reader.ReadLineAsync(); var dataLine = await reader.ReadLineAsync(); var separator = await reader.ReadLineAsync(); Assert.Equal("id: 1", idLine); Assert.Equal("event: pending", eventLine); Assert.NotNull(dataLine); Assert.StartsWith("data: ", dataLine, StringComparison.Ordinal); Assert.Equal(string.Empty, separator); var json = dataLine!["data: ".Length..]; var envelope = JsonSerializer.Deserialize(json, SerializerOptions); Assert.NotNull(envelope); Assert.Equal(submitPayload.ScanId, envelope!.ScanId); Assert.Equal("Pending", envelope.State); Assert.Equal(1, envelope.Sequence); Assert.True(envelope.Timestamp.UtcDateTime <= DateTime.UtcNow); } [Fact] public async Task ProgressStreamDataKeysAreSortedDeterministically() { using var factory = new ScannerApplicationFactory(); using var client = factory.CreateClient(); var request = new ScanSubmitRequest { Image = new ScanImageDescriptor { Reference = "ghcr.io/demo/app:sorted" } }; var submit = await client.PostAsJsonAsync("/api/v1/scans", request); var submitPayload = await submit.Content.ReadFromJsonAsync(); Assert.NotNull(submitPayload); var publisher = factory.Services.GetRequiredService(); var response = await client.GetAsync($"/api/v1/scans/{submitPayload!.ScanId}/events?format=jsonl", HttpCompletionOption.ResponseHeadersRead); await using var stream = await response.Content.ReadAsStreamAsync(); using var reader = new StreamReader(stream); // Drain the initial pending event. _ = await reader.ReadLineAsync(); _ = Task.Run(async () => { await Task.Delay(25); publisher.Publish( new ScanId(submitPayload.ScanId), "Running", "stage-change", new Dictionary { ["zeta"] = 1, ["alpha"] = 2, ["Beta"] = 3 }); }); string? line; JsonDocument? document = null; while ((line = await reader.ReadLineAsync()) is not null) { if (string.IsNullOrWhiteSpace(line)) { continue; } var parsed = JsonDocument.Parse(line); if (parsed.RootElement.TryGetProperty("state", out var state) && string.Equals(state.GetString(), "Running", StringComparison.OrdinalIgnoreCase)) { document = parsed; break; } parsed.Dispose(); } Assert.NotNull(document); using (document) { var data = document!.RootElement.GetProperty("data"); var names = data.EnumerateObject().Select(p => p.Name).ToArray(); Assert.Equal(new[] { "alpha", "Beta", "zeta" }, names); } } [Fact] public async Task GetEntryTraceReturnsStoredResult() { var scanId = $"scan-{Guid.NewGuid():n}"; var generatedAt = new DateTimeOffset(2025, 11, 1, 12, 0, 0, TimeSpan.Zero); var plan = new EntryTracePlan( ImmutableArray.Create("/usr/local/bin/app"), ImmutableDictionary.Empty, "/workspace", "appuser", "/usr/local/bin/app", EntryTraceTerminalType.Native, "go", 90d, ImmutableDictionary.Empty); var terminal = new EntryTraceTerminal( "/usr/local/bin/app", EntryTraceTerminalType.Native, "go", 90d, ImmutableDictionary.Empty, "appuser", "/workspace", ImmutableArray.Empty); var graph = new EntryTraceGraph( EntryTraceOutcome.Resolved, ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Create(plan), ImmutableArray.Create(terminal)); var ndjson = EntryTraceNdjsonWriter.Serialize( graph, new EntryTraceNdjsonMetadata(scanId, "sha256:test", generatedAt)); var storedResult = new EntryTraceResult(scanId, "sha256:test", generatedAt, graph, ndjson); using var factory = new ScannerApplicationFactory( configureConfiguration: null, services => { services.AddSingleton(new StubEntryTraceResultStore(storedResult)); }); using var client = factory.CreateClient(); var response = await client.GetAsync($"/api/v1/scans/{scanId}/entrytrace"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var payload = await response.Content.ReadFromJsonAsync(SerializerOptions, CancellationToken.None); Assert.NotNull(payload); Assert.Equal(storedResult.ScanId, payload!.ScanId); Assert.Equal(storedResult.ImageDigest, payload.ImageDigest); Assert.Equal(storedResult.GeneratedAtUtc, payload.GeneratedAt); Assert.Equal(storedResult.Graph.Plans.Length, payload.Graph.Plans.Length); Assert.Equal(storedResult.Ndjson, payload.Ndjson); } [Fact] public async Task GetEntryTraceReturnsNotFoundWhenMissing() { using var factory = new ScannerApplicationFactory( configureConfiguration: null, services => { services.AddSingleton(new StubEntryTraceResultStore(null)); }); using var client = factory.CreateClient(); var response = await client.GetAsync("/api/v1/scans/scan-missing/entrytrace"); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter() } }; private sealed record ProgressEnvelope( string ScanId, int Sequence, string State, string? Message, DateTimeOffset Timestamp, string CorrelationId, Dictionary Data); private sealed class StubEntryTraceResultStore : IEntryTraceResultStore { private readonly EntryTraceResult? _result; public StubEntryTraceResultStore(EntryTraceResult? result) { _result = result; } public Task GetAsync(string scanId, CancellationToken cancellationToken) { if (_result is not null && string.Equals(_result.ScanId, scanId, StringComparison.Ordinal)) { return Task.FromResult(_result); } return Task.FromResult(null); } public Task StoreAsync(EntryTraceResult result, CancellationToken cancellationToken) { return Task.CompletedTask; } } }