Implement ledger metrics for observability and add tests for Ruby packages endpoints
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Added `LedgerMetrics` class to record write latency and total events for ledger operations. - Created comprehensive tests for Ruby packages endpoints, covering scenarios for missing inventory, successful retrieval, and identifier handling. - Introduced `TestSurfaceSecretsScope` for managing environment variables during tests. - Developed `ProvenanceMongoExtensions` for attaching DSSE provenance and trust information to event documents. - Implemented `EventProvenanceWriter` and `EventWriter` classes for managing event provenance in MongoDB. - Established MongoDB indexes for efficient querying of events based on provenance and trust. - Added models and JSON parsing logic for DSSE provenance and trust information.
This commit is contained in:
@@ -168,12 +168,16 @@ internal static class ScanEndpoints
|
||||
var snapshot = await coordinator.GetAsync(parsed, context.RequestAborted).ConfigureAwait(false);
|
||||
if (snapshot is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"Scan not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: "Requested scan could not be located.");
|
||||
snapshot = await TryResolveSnapshotAsync(scanId, coordinator, cancellationToken).ConfigureAwait(false);
|
||||
if (snapshot is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"Scan not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: "Requested scan could not be located.");
|
||||
}
|
||||
}
|
||||
|
||||
SurfacePointersDto? surfacePointers = null;
|
||||
@@ -282,10 +286,12 @@ internal static class ScanEndpoints
|
||||
|
||||
private static async Task<IResult> HandleEntryTraceAsync(
|
||||
string scanId,
|
||||
IScanCoordinator coordinator,
|
||||
IEntryTraceResultStore resultStore,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(coordinator);
|
||||
ArgumentNullException.ThrowIfNull(resultStore);
|
||||
|
||||
if (!ScanId.TryParse(scanId, out var parsed))
|
||||
@@ -298,15 +304,25 @@ internal static class ScanEndpoints
|
||||
detail: "Scan identifier is required.");
|
||||
}
|
||||
|
||||
var result = await resultStore.GetAsync(parsed.Value, cancellationToken).ConfigureAwait(false);
|
||||
var targetScanId = parsed.Value;
|
||||
var result = await resultStore.GetAsync(targetScanId, cancellationToken).ConfigureAwait(false);
|
||||
if (result is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"EntryTrace not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: "EntryTrace data is not available for the requested scan.");
|
||||
var snapshot = await TryResolveSnapshotAsync(scanId, coordinator, cancellationToken).ConfigureAwait(false);
|
||||
if (snapshot is not null && !string.Equals(snapshot.ScanId.Value, targetScanId, StringComparison.Ordinal))
|
||||
{
|
||||
result = await resultStore.GetAsync(snapshot.ScanId.Value, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"EntryTrace not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: "EntryTrace data is not available for the requested scan.");
|
||||
}
|
||||
}
|
||||
|
||||
var response = new EntryTraceResponse(
|
||||
@@ -321,10 +337,12 @@ internal static class ScanEndpoints
|
||||
|
||||
private static async Task<IResult> HandleRubyPackagesAsync(
|
||||
string scanId,
|
||||
IScanCoordinator coordinator,
|
||||
IRubyPackageInventoryStore inventoryStore,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(coordinator);
|
||||
ArgumentNullException.ThrowIfNull(inventoryStore);
|
||||
|
||||
if (!ScanId.TryParse(scanId, out var parsed))
|
||||
@@ -340,12 +358,27 @@ internal static class ScanEndpoints
|
||||
var inventory = await inventoryStore.GetAsync(parsed.Value, cancellationToken).ConfigureAwait(false);
|
||||
if (inventory is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"Ruby packages not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: "Ruby package inventory is not available for the requested scan.");
|
||||
RubyPackageInventory? fallback = null;
|
||||
if (!LooksLikeScanId(scanId))
|
||||
{
|
||||
var snapshot = await TryResolveSnapshotAsync(scanId, coordinator, cancellationToken).ConfigureAwait(false);
|
||||
if (snapshot is not null)
|
||||
{
|
||||
fallback = await inventoryStore.GetAsync(snapshot.ScanId.Value, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (fallback is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"Ruby packages not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: "Ruby package inventory is not available for the requested scan.");
|
||||
}
|
||||
|
||||
inventory = fallback;
|
||||
}
|
||||
|
||||
var response = new RubyPackagesResponse
|
||||
@@ -420,4 +453,130 @@ internal static class ScanEndpoints
|
||||
var trimmed = segment.Trim('/');
|
||||
return "/" + trimmed;
|
||||
}
|
||||
|
||||
private static async ValueTask<ScanSnapshot?> TryResolveSnapshotAsync(
|
||||
string identifier,
|
||||
IScanCoordinator coordinator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(coordinator);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(identifier))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = identifier.Trim();
|
||||
var decoded = Uri.UnescapeDataString(trimmed);
|
||||
|
||||
if (LooksLikeScanId(decoded))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var (reference, digest) = ExtractTargetHints(decoded);
|
||||
if (reference is null && digest is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await coordinator.TryFindByTargetAsync(reference, digest, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static (string? Reference, string? Digest) ExtractTargetHints(string identifier)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(identifier))
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
var trimmed = identifier.Trim();
|
||||
if (TryExtractDigest(trimmed, out var digest, out var reference))
|
||||
{
|
||||
return (reference, digest);
|
||||
}
|
||||
|
||||
return (trimmed, null);
|
||||
}
|
||||
|
||||
private static bool TryExtractDigest(string candidate, out string? digest, out string? reference)
|
||||
{
|
||||
var atIndex = candidate.IndexOf('@');
|
||||
if (atIndex >= 0 && atIndex < candidate.Length - 1)
|
||||
{
|
||||
var digestCandidate = candidate[(atIndex + 1)..];
|
||||
if (IsDigestValue(digestCandidate))
|
||||
{
|
||||
digest = digestCandidate.ToLowerInvariant();
|
||||
reference = candidate[..atIndex].Trim();
|
||||
if (string.IsNullOrWhiteSpace(reference))
|
||||
{
|
||||
reference = null;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (IsDigestValue(candidate))
|
||||
{
|
||||
digest = candidate.ToLowerInvariant();
|
||||
reference = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
digest = null;
|
||||
reference = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsDigestValue(string value)
|
||||
{
|
||||
var separatorIndex = value.IndexOf(':');
|
||||
if (separatorIndex <= 0 || separatorIndex >= value.Length - 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var algorithm = value[..separatorIndex];
|
||||
var digestPart = value[(separatorIndex + 1)..];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(algorithm) || string.IsNullOrWhiteSpace(digestPart) || digestPart.Length < 32)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var c in digestPart)
|
||||
{
|
||||
if (!IsHexChar(c))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool LooksLikeScanId(string value)
|
||||
{
|
||||
if (value.Length != 40)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var c in value)
|
||||
{
|
||||
if (!IsHexChar(c))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsHexChar(char c)
|
||||
=> (c >= '0' && c <= '9')
|
||||
|| (c >= 'a' && c <= 'f')
|
||||
|| (c >= 'A' && c <= 'F');
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ using StellaOps.Scanner.WebService.Domain;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
public interface IScanCoordinator
|
||||
{
|
||||
ValueTask<ScanSubmissionResult> SubmitAsync(ScanSubmission submission, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<ScanSnapshot?> GetAsync(ScanId scanId, CancellationToken cancellationToken);
|
||||
}
|
||||
public interface IScanCoordinator
|
||||
{
|
||||
ValueTask<ScanSubmissionResult> SubmitAsync(ScanSubmission submission, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<ScanSnapshot?> GetAsync(ScanId scanId, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<ScanSnapshot?> TryFindByTargetAsync(string? reference, string? digest, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -7,11 +7,13 @@ namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
{
|
||||
private sealed record ScanEntry(ScanSnapshot Snapshot);
|
||||
|
||||
private readonly ConcurrentDictionary<string, ScanEntry> scans = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly IScanProgressPublisher progressPublisher;
|
||||
private sealed record ScanEntry(ScanSnapshot Snapshot);
|
||||
|
||||
private readonly ConcurrentDictionary<string, ScanEntry> scans = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, string> scansByDigest = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, string> scansByReference = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly IScanProgressPublisher progressPublisher;
|
||||
|
||||
public InMemoryScanCoordinator(TimeProvider timeProvider, IScanProgressPublisher progressPublisher)
|
||||
{
|
||||
@@ -37,12 +39,12 @@ public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
eventData[$"meta.{pair.Key}"] = pair.Value;
|
||||
}
|
||||
|
||||
ScanEntry entry = scans.AddOrUpdate(
|
||||
scanId.Value,
|
||||
_ => new ScanEntry(new ScanSnapshot(
|
||||
scanId,
|
||||
normalizedTarget,
|
||||
ScanStatus.Pending,
|
||||
ScanEntry entry = scans.AddOrUpdate(
|
||||
scanId.Value,
|
||||
_ => new ScanEntry(new ScanSnapshot(
|
||||
scanId,
|
||||
normalizedTarget,
|
||||
ScanStatus.Pending,
|
||||
now,
|
||||
now,
|
||||
null)),
|
||||
@@ -59,22 +61,87 @@ public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
return new ScanEntry(snapshot);
|
||||
}
|
||||
|
||||
return existing;
|
||||
});
|
||||
|
||||
var created = entry.Snapshot.CreatedAt == now;
|
||||
var state = entry.Snapshot.Status.ToString();
|
||||
progressPublisher.Publish(scanId, state, created ? "queued" : "requeued", eventData);
|
||||
return ValueTask.FromResult(new ScanSubmissionResult(entry.Snapshot, created));
|
||||
}
|
||||
return existing;
|
||||
});
|
||||
|
||||
IndexTarget(scanId.Value, normalizedTarget);
|
||||
|
||||
var created = entry.Snapshot.CreatedAt == now;
|
||||
var state = entry.Snapshot.Status.ToString();
|
||||
progressPublisher.Publish(scanId, state, created ? "queued" : "requeued", eventData);
|
||||
return ValueTask.FromResult(new ScanSubmissionResult(entry.Snapshot, created));
|
||||
}
|
||||
|
||||
public ValueTask<ScanSnapshot?> GetAsync(ScanId scanId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (scans.TryGetValue(scanId.Value, out var entry))
|
||||
{
|
||||
return ValueTask.FromResult<ScanSnapshot?>(entry.Snapshot);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<ScanSnapshot?>(null);
|
||||
}
|
||||
}
|
||||
if (scans.TryGetValue(scanId.Value, out var entry))
|
||||
{
|
||||
return ValueTask.FromResult<ScanSnapshot?>(entry.Snapshot);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<ScanSnapshot?>(null);
|
||||
}
|
||||
|
||||
public ValueTask<ScanSnapshot?> TryFindByTargetAsync(string? reference, string? digest, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
var normalizedDigest = NormalizeDigest(digest);
|
||||
if (normalizedDigest is not null &&
|
||||
scansByDigest.TryGetValue(normalizedDigest, out var digestScanId) &&
|
||||
scans.TryGetValue(digestScanId, out var digestEntry))
|
||||
{
|
||||
return ValueTask.FromResult<ScanSnapshot?>(digestEntry.Snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(reference))
|
||||
{
|
||||
var normalizedReference = NormalizeReference(reference);
|
||||
if (normalizedReference is not null &&
|
||||
scansByReference.TryGetValue(normalizedReference, out var referenceScanId) &&
|
||||
scans.TryGetValue(referenceScanId, out var referenceEntry))
|
||||
{
|
||||
return ValueTask.FromResult<ScanSnapshot?>(referenceEntry.Snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<ScanSnapshot?>(null);
|
||||
}
|
||||
|
||||
private void IndexTarget(string scanId, ScanTarget target)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(target.Digest))
|
||||
{
|
||||
scansByDigest[target.Digest!] = scanId;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(target.Reference))
|
||||
{
|
||||
scansByReference[target.Reference!] = scanId;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? NormalizeDigest(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
return trimmed.Contains(':', StringComparison.Ordinal)
|
||||
? trimmed.ToLowerInvariant()
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string? NormalizeReference(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
| Task ID | State | Notes |
|
||||
| --- | --- | --- |
|
||||
| `SCANNER-ENG-0009` | DOING (2025-11-12) | Added bundler-version metadata + observation summaries, richer CLI output, and the `complex-app` fixture to drive parity validation. |
|
||||
| `SCANNER-ENG-0009` | DONE (2025-11-13) | Ruby analyzer parity landed end-to-end: Mongo-backed `ruby.packages` inventories, WebService `/api/scans/{scanId}/ruby-packages`, CLI `ruby resolve` + observations, plugin manifest packaging, and targeted tests (`StellaOps.Scanner.Analyzers.Lang.Ruby.Tests`, `StellaOps.Scanner.Worker.Tests`, `StellaOps.Scanner.WebService.Tests --filter FullyQualifiedName~RubyPackages`). |
|
||||
| `SCANNER-ENG-0016` | DONE (2025-11-10) | RubyLockCollector merged with vendor cache ingestion; workspace overrides, bundler groups, git/path fixture, and offline-kit mirror updated. |
|
||||
| `SCANNER-ENG-0017` | DONE (2025-11-09) | Build runtime require/autoload graph builder with tree-sitter Ruby per design §4.4, feed EntryTrace hints. |
|
||||
| `SCANNER-ENG-0018` | DONE (2025-11-09) | Emit Ruby capability + framework surface signals, align with design §4.5 / Sprint 138. |
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
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;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
public sealed class RubyPackagesEndpointsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetRubyPackagesReturnsNotFoundWhenInventoryMissing()
|
||||
{
|
||||
using var secrets = new TestSurfaceSecretsScope();
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/scans/scan-ruby-missing/ruby-packages");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRubyPackagesReturnsInventory()
|
||||
{
|
||||
const string scanId = "scan-ruby-existing";
|
||||
const string digest = "sha256:feedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedface";
|
||||
var generatedAt = DateTime.UtcNow.AddMinutes(-10);
|
||||
|
||||
using var secrets = new TestSurfaceSecretsScope();
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
|
||||
using (var serviceScope = factory.Services.CreateScope())
|
||||
{
|
||||
var repository = serviceScope.ServiceProvider.GetRequiredService<RubyPackageInventoryRepository>();
|
||||
var document = new RubyPackageInventoryDocument
|
||||
{
|
||||
ScanId = scanId,
|
||||
ImageDigest = digest,
|
||||
GeneratedAtUtc = generatedAt,
|
||||
Packages = new List<RubyPackageDocument>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = "pkg:gem/rack@3.1.0",
|
||||
Name = "rack",
|
||||
Version = "3.1.0",
|
||||
Source = "rubygems",
|
||||
Platform = "ruby",
|
||||
Groups = new List<string> { "default" },
|
||||
RuntimeUsed = true,
|
||||
Provenance = new RubyPackageProvenance("rubygems", "Gemfile.lock", "Gemfile.lock")
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await repository.UpsertAsync(document, CancellationToken.None);
|
||||
}
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
var response = await client.GetAsync($"/api/v1/scans/{scanId}/ruby-packages");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<RubyPackagesResponse>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal(scanId, payload!.ScanId);
|
||||
Assert.Equal(digest, payload.ImageDigest);
|
||||
Assert.Single(payload.Packages);
|
||||
Assert.Equal("rack", payload.Packages[0].Name);
|
||||
Assert.Equal("rubygems", payload.Packages[0].Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRubyPackagesAllowsDigestIdentifier()
|
||||
{
|
||||
const string reference = "ghcr.io/demo/ruby-service:1.2.3";
|
||||
const string digest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
var generatedAt = DateTime.UtcNow.AddMinutes(-5);
|
||||
|
||||
using var secrets = new TestSurfaceSecretsScope();
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
|
||||
string? scanId = null;
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var coordinator = scope.ServiceProvider.GetRequiredService<IScanCoordinator>();
|
||||
var submission = new ScanSubmission(
|
||||
new ScanTarget(reference, digest),
|
||||
Force: false,
|
||||
ClientRequestId: null,
|
||||
Metadata: new Dictionary<string, string>());
|
||||
var result = await coordinator.SubmitAsync(submission, CancellationToken.None);
|
||||
scanId = result.Snapshot.ScanId.Value;
|
||||
|
||||
var resolved = await coordinator.TryFindByTargetAsync(reference, digest, CancellationToken.None);
|
||||
Assert.NotNull(resolved);
|
||||
|
||||
var repository = scope.ServiceProvider.GetRequiredService<RubyPackageInventoryRepository>();
|
||||
var document = new RubyPackageInventoryDocument
|
||||
{
|
||||
ScanId = scanId,
|
||||
ImageDigest = digest,
|
||||
GeneratedAtUtc = generatedAt,
|
||||
Packages = new List<RubyPackageDocument>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = "pkg:gem/rails@7.1.0",
|
||||
Name = "rails",
|
||||
Version = "7.1.0",
|
||||
Source = "rubygems"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await repository.UpsertAsync(document, CancellationToken.None);
|
||||
}
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
var encodedDigest = Uri.EscapeDataString(digest);
|
||||
var response = await client.GetAsync($"/api/v1/scans/{encodedDigest}/ruby-packages");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var payload = await response.Content.ReadFromJsonAsync<RubyPackagesResponse>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal(scanId, payload!.ScanId);
|
||||
Assert.Equal(digest, payload.ImageDigest);
|
||||
Assert.Single(payload.Packages);
|
||||
Assert.Equal("rails", payload.Packages[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRubyPackagesAllowsReferenceIdentifier()
|
||||
{
|
||||
const string reference = "ghcr.io/demo/ruby-service:latest";
|
||||
const string digest = "sha512:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd";
|
||||
using var secrets = new TestSurfaceSecretsScope();
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
|
||||
string? scanId = null;
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var coordinator = scope.ServiceProvider.GetRequiredService<IScanCoordinator>();
|
||||
var submission = new ScanSubmission(
|
||||
new ScanTarget(reference, digest),
|
||||
Force: false,
|
||||
ClientRequestId: "cli-test",
|
||||
Metadata: new Dictionary<string, string>());
|
||||
var result = await coordinator.SubmitAsync(submission, CancellationToken.None);
|
||||
scanId = result.Snapshot.ScanId.Value;
|
||||
|
||||
var resolved = await coordinator.TryFindByTargetAsync(reference, digest, CancellationToken.None);
|
||||
Assert.NotNull(resolved);
|
||||
|
||||
var repository = scope.ServiceProvider.GetRequiredService<RubyPackageInventoryRepository>();
|
||||
var document = new RubyPackageInventoryDocument
|
||||
{
|
||||
ScanId = scanId,
|
||||
ImageDigest = digest,
|
||||
GeneratedAtUtc = DateTime.UtcNow.AddMinutes(-2),
|
||||
Packages = new List<RubyPackageDocument>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = "pkg:gem/sidekiq@7.2.1",
|
||||
Name = "sidekiq",
|
||||
Version = "7.2.1",
|
||||
Source = "rubygems"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await repository.UpsertAsync(document, CancellationToken.None);
|
||||
}
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
var encodedReference = Uri.EscapeDataString(reference);
|
||||
var response = await client.GetAsync($"/api/v1/scans/{encodedReference}/ruby-packages");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var payload = await response.Content.ReadFromJsonAsync<RubyPackagesResponse>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal(scanId, payload!.ScanId);
|
||||
Assert.Equal(digest, payload.ImageDigest);
|
||||
Assert.Single(payload.Packages);
|
||||
Assert.Equal("sidekiq", payload.Packages[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEntryTraceAllowsDigestIdentifier()
|
||||
{
|
||||
const string reference = "ghcr.io/demo/app:2.0.0";
|
||||
const string digest = "sha256:111122223333444455556666777788889999aaaabbbbccccddddeeeeffff0000";
|
||||
var generatedAt = DateTimeOffset.UtcNow.AddMinutes(-1);
|
||||
|
||||
var plan = new EntryTracePlan(
|
||||
ImmutableArray.Create("/app/bin/app"),
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
"/workspace",
|
||||
"appuser",
|
||||
"/app/bin/app",
|
||||
EntryTraceTerminalType.Native,
|
||||
"ruby",
|
||||
0.85,
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var terminal = new EntryTraceTerminal(
|
||||
"/app/bin/app",
|
||||
EntryTraceTerminalType.Native,
|
||||
"ruby",
|
||||
0.85,
|
||||
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("scan-placeholder", digest, generatedAt));
|
||||
|
||||
using var secrets = new TestSurfaceSecretsScope();
|
||||
using var factory = new ScannerApplicationFactory(configureServices: services =>
|
||||
{
|
||||
services.AddSingleton<IEntryTraceResultStore, RecordingEntryTraceResultStore>();
|
||||
});
|
||||
|
||||
string? canonicalScanId = null;
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var coordinator = scope.ServiceProvider.GetRequiredService<IScanCoordinator>();
|
||||
var submission = new ScanSubmission(
|
||||
new ScanTarget(reference, digest),
|
||||
Force: false,
|
||||
ClientRequestId: null,
|
||||
Metadata: new Dictionary<string, string>());
|
||||
var result = await coordinator.SubmitAsync(submission, CancellationToken.None);
|
||||
canonicalScanId = result.Snapshot.ScanId.Value;
|
||||
|
||||
var store = (RecordingEntryTraceResultStore)scope.ServiceProvider.GetRequiredService<IEntryTraceResultStore>();
|
||||
store.Set(new EntryTraceResult(canonicalScanId, digest, generatedAt, graph, ndjson));
|
||||
}
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
var encodedDigest = Uri.EscapeDataString(digest);
|
||||
var response = await client.GetAsync($"/api/v1/scans/{encodedDigest}/entrytrace");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<EntryTraceResponse>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal(canonicalScanId, payload!.ScanId);
|
||||
Assert.Equal(digest, payload.ImageDigest);
|
||||
Assert.Equal(graph.Plans.Length, payload.Graph.Plans.Length);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SurfaceSecretsScope : IDisposable
|
||||
{
|
||||
private readonly string? _provider;
|
||||
private readonly string? _root;
|
||||
|
||||
public SurfaceSecretsScope()
|
||||
{
|
||||
_provider = Environment.GetEnvironmentVariable("SURFACE_SECRETS_PROVIDER");
|
||||
_root = Environment.GetEnvironmentVariable("SURFACE_SECRETS_ROOT");
|
||||
Environment.SetEnvironmentVariable("SURFACE_SECRETS_PROVIDER", "file");
|
||||
Environment.SetEnvironmentVariable("SURFACE_SECRETS_ROOT", Path.GetTempPath());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SURFACE_SECRETS_PROVIDER", _provider);
|
||||
Environment.SetEnvironmentVariable("SURFACE_SECRETS_ROOT", _root);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RecordingEntryTraceResultStore : IEntryTraceResultStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, EntryTraceResult> _entries = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public void Set(EntryTraceResult result)
|
||||
{
|
||||
if (result is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(result));
|
||||
}
|
||||
|
||||
_entries[result.ScanId] = result;
|
||||
}
|
||||
|
||||
public Task<EntryTraceResult?> GetAsync(string scanId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_entries.TryGetValue(scanId, out var value))
|
||||
{
|
||||
return Task.FromResult<EntryTraceResult?>(value);
|
||||
}
|
||||
|
||||
return Task.FromResult<EntryTraceResult?>(null);
|
||||
}
|
||||
|
||||
public Task StoreAsync(EntryTraceResult result, CancellationToken cancellationToken)
|
||||
{
|
||||
Set(result);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,665 +1,84 @@
|
||||
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 System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.EntryTrace;
|
||||
using StellaOps.Scanner.EntryTrace.Serialization;
|
||||
using StellaOps.Scanner.Storage.Catalog;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
using StellaOps.Scanner.Storage.ObjectStore;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
public sealed class ScansEndpointsTests
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
public sealed partial 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();
|
||||
|
||||
const string manifestDigest = "sha256:b2efc2d1f8b042b7f168bcb7d4e2f8e91d36b8306bd855382c5f847efc2c1111";
|
||||
const string graphDigest = "sha256:9a0d4f8c7b6a5e4d3c2b1a0f9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a291819";
|
||||
const string ndjsonDigest = "sha256:3f2e1d0c9b8a7f6e5d4c3b2a1908f7e6d5c4b3a29181726354433221100ffeec";
|
||||
const string fragmentsDigest = "sha256:aa55aa55aa55aa55aa55aa55aa55aa55aa55aa55aa55aa55aa55aa55aa55aa55";
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var artifactRepository = scope.ServiceProvider.GetRequiredService<ArtifactRepository>();
|
||||
var linkRepository = scope.ServiceProvider.GetRequiredService<LinkRepository>();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
async Task InsertAsync(
|
||||
ArtifactDocumentType type,
|
||||
ArtifactDocumentFormat format,
|
||||
string artifactDigest,
|
||||
string mediaType,
|
||||
string ttlClass)
|
||||
{
|
||||
var artifactId = CatalogIdFactory.CreateArtifactId(type, artifactDigest);
|
||||
var document = new ArtifactDocument
|
||||
{
|
||||
Id = artifactId,
|
||||
Type = type,
|
||||
Format = format,
|
||||
MediaType = mediaType,
|
||||
BytesSha256 = artifactDigest,
|
||||
SizeBytes = 2048,
|
||||
Immutable = true,
|
||||
RefCount = 1,
|
||||
TtlClass = ttlClass,
|
||||
CreatedAtUtc = now,
|
||||
UpdatedAtUtc = now
|
||||
};
|
||||
|
||||
await artifactRepository.UpsertAsync(document, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
var link = new LinkDocument
|
||||
{
|
||||
Id = CatalogIdFactory.CreateLinkId(LinkSourceType.Image, digest, artifactId),
|
||||
FromType = LinkSourceType.Image,
|
||||
FromDigest = digest,
|
||||
ArtifactId = artifactId,
|
||||
CreatedAtUtc = now
|
||||
};
|
||||
|
||||
await linkRepository.UpsertAsync(link, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await InsertAsync(
|
||||
ArtifactDocumentType.ImageBom,
|
||||
ArtifactDocumentFormat.CycloneDxJson,
|
||||
digest,
|
||||
"application/vnd.cyclonedx+json; version=1.6; view=inventory",
|
||||
"default").ConfigureAwait(false);
|
||||
|
||||
await InsertAsync(
|
||||
ArtifactDocumentType.SurfaceManifest,
|
||||
ArtifactDocumentFormat.SurfaceManifestJson,
|
||||
manifestDigest,
|
||||
"application/vnd.stellaops.surface.manifest+json",
|
||||
"surface.manifest").ConfigureAwait(false);
|
||||
|
||||
await InsertAsync(
|
||||
ArtifactDocumentType.SurfaceEntryTrace,
|
||||
ArtifactDocumentFormat.EntryTraceGraphJson,
|
||||
graphDigest,
|
||||
"application/json",
|
||||
"surface.payload").ConfigureAwait(false);
|
||||
|
||||
await InsertAsync(
|
||||
ArtifactDocumentType.SurfaceEntryTrace,
|
||||
ArtifactDocumentFormat.EntryTraceNdjson,
|
||||
ndjsonDigest,
|
||||
"application/x-ndjson",
|
||||
"surface.payload").ConfigureAwait(false);
|
||||
|
||||
await InsertAsync(
|
||||
ArtifactDocumentType.SurfaceLayerFragment,
|
||||
ArtifactDocumentFormat.ComponentFragmentJson,
|
||||
fragmentsDigest,
|
||||
"application/json",
|
||||
"surface.payload").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 artifactsByKind = manifest.Artifacts.ToDictionary(a => a.Kind, StringComparer.Ordinal);
|
||||
Assert.Equal(5, artifactsByKind.Count);
|
||||
|
||||
static string BuildUri(ArtifactDocumentType type, ArtifactDocumentFormat format, string digestValue)
|
||||
=> $"cas://scanner-artifacts/{ArtifactObjectKeyBuilder.Build(type, format, digestValue, \"scanner\")}";
|
||||
|
||||
var inventory = artifactsByKind["sbom-inventory"];
|
||||
Assert.Equal(digest, inventory.Digest);
|
||||
Assert.Equal("cdx-json", inventory.Format);
|
||||
Assert.Equal("application/vnd.cyclonedx+json; version=1.6; view=inventory", inventory.MediaType);
|
||||
Assert.Equal("inventory", inventory.View);
|
||||
Assert.Equal(BuildUri(ArtifactDocumentType.ImageBom, ArtifactDocumentFormat.CycloneDxJson, digest), inventory.Uri);
|
||||
|
||||
var manifestArtifact = artifactsByKind["surface.manifest"];
|
||||
Assert.Equal(manifestDigest, manifestArtifact.Digest);
|
||||
Assert.Equal("surface.manifest", manifestArtifact.Format);
|
||||
Assert.Equal("application/vnd.stellaops.surface.manifest+json", manifestArtifact.MediaType);
|
||||
Assert.Null(manifestArtifact.View);
|
||||
Assert.Equal(BuildUri(ArtifactDocumentType.SurfaceManifest, ArtifactDocumentFormat.SurfaceManifestJson, manifestDigest), manifestArtifact.Uri);
|
||||
|
||||
var graphArtifact = artifactsByKind["entrytrace.graph"];
|
||||
Assert.Equal(graphDigest, graphArtifact.Digest);
|
||||
Assert.Equal("entrytrace.graph", graphArtifact.Format);
|
||||
Assert.Equal("application/json", graphArtifact.MediaType);
|
||||
Assert.Null(graphArtifact.View);
|
||||
Assert.Equal(BuildUri(ArtifactDocumentType.SurfaceEntryTrace, ArtifactDocumentFormat.EntryTraceGraphJson, graphDigest), graphArtifact.Uri);
|
||||
|
||||
var ndjsonArtifact = artifactsByKind["entrytrace.ndjson"];
|
||||
Assert.Equal(ndjsonDigest, ndjsonArtifact.Digest);
|
||||
Assert.Equal("entrytrace.ndjson", ndjsonArtifact.Format);
|
||||
Assert.Equal("application/x-ndjson", ndjsonArtifact.MediaType);
|
||||
Assert.Null(ndjsonArtifact.View);
|
||||
Assert.Equal(BuildUri(ArtifactDocumentType.SurfaceEntryTrace, ArtifactDocumentFormat.EntryTraceNdjson, ndjsonDigest), ndjsonArtifact.Uri);
|
||||
|
||||
var fragmentsArtifact = artifactsByKind["layer.fragments"];
|
||||
Assert.Equal(fragmentsDigest, fragmentsArtifact.Digest);
|
||||
Assert.Equal("layer.fragments", fragmentsArtifact.Format);
|
||||
Assert.Equal("application/json", fragmentsArtifact.MediaType);
|
||||
Assert.Equal("inventory", fragmentsArtifact.View);
|
||||
Assert.Equal(BuildUri(ArtifactDocumentType.SurfaceLayerFragment, ArtifactDocumentFormat.ComponentFragmentJson, fragmentsDigest), fragmentsArtifact.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 secrets = new TestSurfaceSecretsScope();
|
||||
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)));
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var ndjson = new List<string> { "{\"kind\":\"entry\"}" };
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
var response = await client.PostAsJsonAsync("/api/v1/scans", new
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RubyPackagesEndpointReturnsNotFoundWhenMissing()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/scans/scan-ruby-missing/ruby-packages");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RubyPackagesEndpointReturnsInventory()
|
||||
{
|
||||
const string scanId = "scan-ruby-existing";
|
||||
const string digest = "sha256:feedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedface";
|
||||
var generatedAt = DateTime.UtcNow.AddMinutes(-10);
|
||||
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var repository = scope.ServiceProvider.GetRequiredService<RubyPackageInventoryRepository>();
|
||||
var document = new RubyPackageInventoryDocument
|
||||
{
|
||||
ScanId = scanId,
|
||||
ImageDigest = digest,
|
||||
GeneratedAtUtc = generatedAt,
|
||||
Packages = new List<RubyPackageDocument>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = "pkg:gem/rack@3.1.0",
|
||||
Name = "rack",
|
||||
Version = "3.1.0",
|
||||
Source = "rubygems",
|
||||
Platform = "ruby",
|
||||
Groups = new List<string> { "default" },
|
||||
RuntimeUsed = true,
|
||||
Provenance = new RubyPackageProvenance("rubygems", "Gemfile.lock", "Gemfile.lock")
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await repository.UpsertAsync(document, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
var response = await client.GetAsync($"/api/v1/scans/{scanId}/ruby-packages");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<RubyPackagesResponse>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal(scanId, payload!.ScanId);
|
||||
Assert.Equal(digest, payload.ImageDigest);
|
||||
Assert.Single(payload.Packages);
|
||||
Assert.Equal("rack", payload.Packages[0].Name);
|
||||
Assert.Equal("rubygems", payload.Packages[0].Source);
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
image = new { reference = string.Empty, digest = string.Empty }
|
||||
});
|
||||
|
||||
string? line;
|
||||
JsonDocument? document = null;
|
||||
while ((line = await reader.ReadLineAsync()) is not null)
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubmitScanPropagatesRequestAbortedToken()
|
||||
{
|
||||
using var secrets = new TestSurfaceSecretsScope();
|
||||
RecordingCoordinator coordinator = null!;
|
||||
|
||||
using var factory = new ScannerApplicationFactory(configuration =>
|
||||
{
|
||||
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)
|
||||
configuration["scanner:authority:enabled"] = "false";
|
||||
}, configureServices: services =>
|
||||
{
|
||||
var data = document!.RootElement.GetProperty("data");
|
||||
var names = data.EnumerateObject().Select(p => p.Name).ToArray();
|
||||
Assert.Equal(new[] { "alpha", "Beta", "zeta" }, names);
|
||||
}
|
||||
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 GetEntryTraceReturnsStoredResult()
|
||||
{
|
||||
using var secrets = new TestSurfaceSecretsScope();
|
||||
var scanId = $"scan-{Guid.NewGuid():n}";
|
||||
var generatedAt = new DateTimeOffset(2025, 11, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
var generatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
var plan = new EntryTracePlan(
|
||||
ImmutableArray.Create("/usr/local/bin/app"),
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
@@ -668,17 +87,19 @@ public sealed class ScansEndpointsTests
|
||||
"/usr/local/bin/app",
|
||||
EntryTraceTerminalType.Native,
|
||||
"go",
|
||||
90d,
|
||||
0.9,
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var terminal = new EntryTraceTerminal(
|
||||
"/usr/local/bin/app",
|
||||
EntryTraceTerminalType.Native,
|
||||
"go",
|
||||
90d,
|
||||
0.9,
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
"appuser",
|
||||
"/workspace",
|
||||
ImmutableArray<string>.Empty);
|
||||
|
||||
var graph = new EntryTraceGraph(
|
||||
EntryTraceOutcome.Resolved,
|
||||
ImmutableArray<EntryTraceNode>.Empty,
|
||||
@@ -686,59 +107,67 @@ public sealed class ScansEndpointsTests
|
||||
ImmutableArray<EntryTraceDiagnostic>.Empty,
|
||||
ImmutableArray.Create(plan),
|
||||
ImmutableArray.Create(terminal));
|
||||
var ndjson = EntryTraceNdjsonWriter.Serialize(
|
||||
graph,
|
||||
new EntryTraceNdjsonMetadata(scanId, "sha256:test", generatedAt));
|
||||
|
||||
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 factory = new ScannerApplicationFactory(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>(SerializerOptions, CancellationToken.None);
|
||||
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.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 secrets = new TestSurfaceSecretsScope();
|
||||
using var factory = new ScannerApplicationFactory(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 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 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 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);
|
||||
|
||||
public ValueTask<ScanSnapshot?> TryFindByTargetAsync(string? reference, string? digest, CancellationToken cancellationToken)
|
||||
=> _inner.TryFindByTargetAsync(reference, digest, cancellationToken);
|
||||
}
|
||||
|
||||
private sealed class StubEntryTraceResultStore : IEntryTraceResultStore
|
||||
{
|
||||
@@ -760,8 +189,6 @@ public sealed class ScansEndpointsTests
|
||||
}
|
||||
|
||||
public Task StoreAsync(EntryTraceResult result, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
internal sealed class TestSurfaceSecretsScope : IDisposable
|
||||
{
|
||||
private readonly string? _provider;
|
||||
private readonly string? _root;
|
||||
|
||||
public TestSurfaceSecretsScope()
|
||||
{
|
||||
_provider = Environment.GetEnvironmentVariable("SURFACE_SECRETS_PROVIDER");
|
||||
_root = Environment.GetEnvironmentVariable("SURFACE_SECRETS_ROOT");
|
||||
Environment.SetEnvironmentVariable("SURFACE_SECRETS_PROVIDER", "file");
|
||||
Environment.SetEnvironmentVariable("SURFACE_SECRETS_ROOT", Path.GetTempPath());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SURFACE_SECRETS_PROVIDER", _provider);
|
||||
Environment.SetEnvironmentVariable("SURFACE_SECRETS_ROOT", _root);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user