Files
git.stella-ops.org/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/ScansEndpointsTests.cs
StellaOps Bot 7d5250238c save progress
2025-12-18 09:53:46 +02:00

241 lines
9.3 KiB
C#

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<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
});
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<IScanCoordinator>(sp =>
{
coordinator = new RecordingCoordinator(
sp.GetRequiredService<IHttpContextAccessor>(),
sp.GetRequiredService<TimeProvider>(),
sp.GetRequiredService<IScanProgressPublisher>());
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<string, string>.Empty,
"/workspace",
"appuser",
"/usr/local/bin/app",
EntryTraceTerminalType.Native,
"go",
0.9,
ImmutableDictionary<string, string>.Empty);
var terminal = new EntryTraceTerminal(
"/usr/local/bin/app",
EntryTraceTerminalType.Native,
"go",
0.9,
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().WithOverrides(configureServices: 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>();
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<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 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<ScanSubmissionResult> SubmitAsync(ScanSubmission submission, CancellationToken cancellationToken)
{
LastToken = cancellationToken;
TokenMatched = _accessor.HttpContext?.RequestAborted.Equals(cancellationToken) ?? false;
LastSubmission = submission;
return await _inner.SubmitAsync(submission, cancellationToken);
}
public ValueTask<ScanSnapshot?> GetAsync(ScanId scanId, CancellationToken cancellationToken)
=> _inner.GetAsync(scanId, cancellationToken);
public ValueTask<ScanSnapshot?> TryFindByTargetAsync(string? reference, string? digest, CancellationToken cancellationToken)
=> _inner.TryFindByTargetAsync(reference, digest, cancellationToken);
public ValueTask<bool> AttachReplayAsync(ScanId scanId, ReplayArtifacts replay, CancellationToken cancellationToken)
=> _inner.AttachReplayAsync(scanId, replay, cancellationToken);
public ValueTask<bool> 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<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)
=> Task.CompletedTask;
}
}