241 lines
9.3 KiB
C#
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;
|
|
}
|
|
}
|