Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- 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.
626 lines
24 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|