Files
git.stella-ops.org/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/ScansEndpointsTests.cs
master 950f238a93
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Add MergeUsageAnalyzer to detect legacy merge service usage
- Implemented MergeUsageAnalyzer to flag usage of AdvisoryMergeService and AddMergeModule.
- Created AnalyzerReleases.Shipped.md and AnalyzerReleases.Unshipped.md for release documentation.
- Added tests for MergeUsageAnalyzer to ensure correct diagnostics for various scenarios.
- Updated project files for analyzers and tests to include necessary dependencies and configurations.
- Introduced a sample report structure for scanner output.
2025-11-06 15:03:39 +02:00

626 lines
24 KiB
C#

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<ScanSubmitResponse>();
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<ScanStatusResponse>();
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<string, string> { ["origin"] = "unit-test" }
};
var first = await client.PostAsJsonAsync("/api/v1/scans", request);
var firstPayload = await first.Content.ReadFromJsonAsync<ScanSubmitResponse>();
var second = await client.PostAsJsonAsync("/api/v1/scans", request);
var secondPayload = await second.Content.ReadFromJsonAsync<ScanSubmitResponse>();
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<ArtifactRepository>();
var linkRepository = scope.ServiceProvider.GetRequiredService<LinkRepository>();
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<ScanSubmitResponse>();
Assert.NotNull(submission);
var statusResponse = await client.GetAsync($"/api/v1/scans/{submission!.ScanId}");
statusResponse.EnsureSuccessStatusCode();
var status = await statusResponse.Content.ReadFromJsonAsync<ScanStatusResponse>();
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<IScanCoordinator>(sp =>
{
coordinator = new RecordingCoordinator(
sp.GetRequiredService<IHttpContextAccessor>(),
sp.GetRequiredService<TimeProvider>(),
sp.GetRequiredService<IScanProgressPublisher>());
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<EntryTraceNode>.Empty,
ImmutableArray<EntryTraceEdge>.Empty,
ImmutableArray<EntryTraceDiagnostic>.Empty,
ImmutableArray.Create(new EntryTracePlan(
ImmutableArray.Create("/bin/bash", "-lc", "./start.sh"),
ImmutableDictionary<string, string>.Empty,
"/workspace",
"root",
"/bin/bash",
EntryTraceTerminalType.Script,
"bash",
0.9,
ImmutableDictionary<string, string>.Empty)),
ImmutableArray.Create(new EntryTraceTerminal(
"/bin/bash",
EntryTraceTerminalType.Script,
"bash",
0.9,
ImmutableDictionary<string, string>.Empty,
"root",
"/workspace",
ImmutableArray<string>.Empty)));
var ndjson = new List<string> { "{\"kind\":\"entry\"}" };
using (var scope = factory.Services.CreateScope())
{
var repository = scope.ServiceProvider.GetRequiredService<EntryTraceRepository>();
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<EntryTraceResponse>(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<ScanSubmissionResult> SubmitAsync(ScanSubmission submission, CancellationToken cancellationToken)
{
LastToken = cancellationToken;
TokenMatched = accessor.HttpContext?.RequestAborted.Equals(cancellationToken) ?? false;
return await inner.SubmitAsync(submission, cancellationToken);
}
public ValueTask<ScanSnapshot?> 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<ScanSubmitResponse>();
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<ProgressEnvelope>(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<ScanSubmitResponse>();
Assert.NotNull(submitPayload);
var publisher = factory.Services.GetRequiredService<IScanProgressPublisher>();
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<ProgressEnvelope>(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<string, object?>
{
["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<ProgressEnvelope>(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<ScanSubmitResponse>();
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<ProgressEnvelope>(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<ScanSubmitResponse>();
Assert.NotNull(submitPayload);
var publisher = factory.Services.GetRequiredService<IScanProgressPublisher>();
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<string, object?>
{
["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<string, string>.Empty,
"/workspace",
"appuser",
"/usr/local/bin/app",
EntryTraceTerminalType.Native,
"go",
90d,
ImmutableDictionary<string, string>.Empty);
var terminal = new EntryTraceTerminal(
"/usr/local/bin/app",
EntryTraceTerminalType.Native,
"go",
90d,
ImmutableDictionary<string, string>.Empty,
"appuser",
"/workspace",
ImmutableArray<string>.Empty);
var graph = new EntryTraceGraph(
EntryTraceOutcome.Resolved,
ImmutableArray<EntryTraceNode>.Empty,
ImmutableArray<EntryTraceEdge>.Empty,
ImmutableArray<EntryTraceDiagnostic>.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<IEntryTraceResultStore>(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<EntryTraceResponse>(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<IEntryTraceResultStore>(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<string, JsonElement> Data);
private sealed class StubEntryTraceResultStore : IEntryTraceResultStore
{
private readonly EntryTraceResult? _result;
public StubEntryTraceResultStore(EntryTraceResult? result)
{
_result = result;
}
public Task<EntryTraceResult?> GetAsync(string scanId, CancellationToken cancellationToken)
{
if (_result is not null && string.Equals(_result.ScanId, scanId, StringComparison.Ordinal))
{
return Task.FromResult<EntryTraceResult?>(_result);
}
return Task.FromResult<EntryTraceResult?>(null);
}
public Task StoreAsync(EntryTraceResult result, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
}