using System.Collections.Generic; using System.Collections.Immutable; using System.Net; using System.Net.Http.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; using StellaOps.Scanner.EntryTrace; using StellaOps.Scanner.EntryTrace.Serialization; using StellaOps.Scanner.WebService.Contracts; using StellaOps.Scanner.WebService.Domain; using StellaOps.Scanner.WebService.Services; using Xunit; namespace StellaOps.Scanner.WebService.Tests; public sealed partial class ScansEndpointsTests { [Fact] public async Task SubmitScanValidatesImageDescriptor() { using var secrets = new TestSurfaceSecretsScope(); using var factory = new ScannerApplicationFactory(); using var client = factory.CreateClient(); var response = await client.PostAsJsonAsync("/api/v1/scans", new { image = new { reference = string.Empty, digest = string.Empty } }); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } [Fact] public async Task SubmitScanPropagatesRequestAbortedToken() { using var secrets = new TestSurfaceSecretsScope(); RecordingCoordinator coordinator = null!; using var factory = new ScannerApplicationFactory().WithOverrides(configuration => { configuration["scanner:authority:enabled"] = "false"; }, configureServices: services => { services.AddSingleton(sp => { coordinator = new RecordingCoordinator( sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService()); return coordinator; }); }); using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); using 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 SubmitScanAddsDeterminismPinsToMetadata() { using var secrets = new TestSurfaceSecretsScope(); RecordingCoordinator coordinator = null!; using var factory = new ScannerApplicationFactory().WithOverrides(configuration => { configuration["scanner:determinism:feedSnapshotId"] = "feed-2025-11-26"; configuration["scanner:determinism:policySnapshotId"] = "rev-42"; }, configureServices: services => { services.AddSingleton(sp => { coordinator = new RecordingCoordinator( sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService()); return coordinator; }); }); using var client = factory.CreateClient(); var request = new ScanSubmitRequest { Image = new ScanImageDescriptor { Reference = "example.com/demo:1.0" } }; var response = await client.PostAsJsonAsync("/api/v1/scans", request); Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); Assert.NotNull(coordinator?.LastSubmission); var metadata = coordinator!.LastSubmission!.Metadata; Assert.Equal("feed-2025-11-26", metadata["determinism.feed"]); Assert.Equal("rev-42", metadata["determinism.policy"]); } [Fact] public async Task GetEntryTraceReturnsStoredResult() { using var secrets = new TestSurfaceSecretsScope(); var scanId = $"scan-{Guid.NewGuid():n}"; var generatedAt = DateTimeOffset.UtcNow; var plan = new EntryTracePlan( ImmutableArray.Create("/usr/local/bin/app"), ImmutableDictionary.Empty, "/workspace", "appuser", "/usr/local/bin/app", EntryTraceTerminalType.Native, "go", 0.9, ImmutableDictionary.Empty); var terminal = new EntryTraceTerminal( "/usr/local/bin/app", EntryTraceTerminalType.Native, "go", 0.9, 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().WithOverrides(configureServices: 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(); Assert.NotNull(payload); Assert.Equal(storedResult.ScanId, payload!.ScanId); Assert.Equal(storedResult.ImageDigest, payload.ImageDigest); Assert.Equal(storedResult.Graph.Plans.Length, payload.Graph.Plans.Length); } [Fact] public async Task GetEntryTraceReturnsNotFoundWhenMissing() { using var secrets = new TestSurfaceSecretsScope(); using var factory = new ScannerApplicationFactory().WithOverrides(configureServices: 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 sealed class RecordingCoordinator : IScanCoordinator { private readonly IHttpContextAccessor _accessor; private readonly InMemoryScanCoordinator _inner; public RecordingCoordinator(IHttpContextAccessor accessor, TimeProvider timeProvider, IScanProgressPublisher publisher) { _accessor = accessor; _inner = new InMemoryScanCoordinator(timeProvider, publisher); } public CancellationToken LastToken { get; private set; } public bool TokenMatched { get; private set; } public ScanSubmission? LastSubmission { get; private set; } public async ValueTask SubmitAsync(ScanSubmission submission, CancellationToken cancellationToken) { LastToken = cancellationToken; TokenMatched = _accessor.HttpContext?.RequestAborted.Equals(cancellationToken) ?? false; LastSubmission = submission; return await _inner.SubmitAsync(submission, cancellationToken); } public ValueTask GetAsync(ScanId scanId, CancellationToken cancellationToken) => _inner.GetAsync(scanId, cancellationToken); public ValueTask TryFindByTargetAsync(string? reference, string? digest, CancellationToken cancellationToken) => _inner.TryFindByTargetAsync(reference, digest, cancellationToken); public ValueTask AttachReplayAsync(ScanId scanId, ReplayArtifacts replay, CancellationToken cancellationToken) => _inner.AttachReplayAsync(scanId, replay, cancellationToken); public ValueTask AttachEntropyAsync(ScanId scanId, EntropySnapshot entropy, CancellationToken cancellationToken) => _inner.AttachEntropyAsync(scanId, entropy, cancellationToken); } 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) => Task.CompletedTask; } }