Restructure solution layout by module
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
using System.Net;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
public sealed class AuthorizationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ApiRoutesRequireAuthenticationWhenAuthorityEnabled()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory(configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "true";
|
||||
configuration["scanner:authority:allowAnonymousFallback"] = "false";
|
||||
configuration["scanner:authority:issuer"] = "https://authority.local";
|
||||
configuration["scanner:authority:audiences:0"] = "scanner-api";
|
||||
configuration["scanner:authority:clientId"] = "scanner-web";
|
||||
configuration["scanner:authority:clientSecret"] = "secret";
|
||||
});
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
var response = await client.GetAsync("/api/v1/__auth-probe");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
public sealed class HealthEndpointsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task HealthAndReadyEndpointsRespond()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var healthResponse = await client.GetAsync("/healthz");
|
||||
Assert.True(healthResponse.IsSuccessStatusCode, $"Expected 200 from /healthz, received {(int)healthResponse.StatusCode}.");
|
||||
|
||||
var readyResponse = await client.GetAsync("/readyz");
|
||||
Assert.True(readyResponse.IsSuccessStatusCode, $"Expected 200 from /readyz, received {(int)readyResponse.StatusCode}.");
|
||||
|
||||
var healthDocument = await healthResponse.Content.ReadFromJsonAsync<HealthDocument>();
|
||||
Assert.NotNull(healthDocument);
|
||||
Assert.Equal("healthy", healthDocument!.Status);
|
||||
Assert.True(healthDocument.UptimeSeconds >= 0);
|
||||
Assert.NotNull(healthDocument.Telemetry);
|
||||
|
||||
var readyDocument = await readyResponse.Content.ReadFromJsonAsync<ReadyDocument>();
|
||||
Assert.NotNull(readyDocument);
|
||||
Assert.Equal("ready", readyDocument!.Status);
|
||||
Assert.Null(readyDocument.Error);
|
||||
}
|
||||
|
||||
private sealed record HealthDocument(
|
||||
string Status,
|
||||
DateTimeOffset StartedAt,
|
||||
DateTimeOffset CapturedAt,
|
||||
double UptimeSeconds,
|
||||
TelemetryDocument Telemetry);
|
||||
|
||||
private sealed record TelemetryDocument(
|
||||
bool Enabled,
|
||||
bool Logging,
|
||||
bool Metrics,
|
||||
bool Tracing);
|
||||
|
||||
private sealed record ReadyDocument(
|
||||
string Status,
|
||||
DateTimeOffset CheckedAt,
|
||||
double? LatencyMs,
|
||||
string? Error);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
public sealed class PlatformEventPublisherRegistrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void NullPublisherRegisteredWhenEventsDisabled()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory(configuration =>
|
||||
{
|
||||
configuration["scanner:events:enabled"] = "false";
|
||||
configuration["scanner:events:dsn"] = string.Empty;
|
||||
});
|
||||
using var scope = factory.Services.CreateScope();
|
||||
|
||||
var publisher = scope.ServiceProvider.GetRequiredService<IPlatformEventPublisher>();
|
||||
Assert.IsType<NullPlatformEventPublisher>(publisher);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedisPublisherRegisteredWhenEventsEnabled()
|
||||
{
|
||||
var originalEnabled = Environment.GetEnvironmentVariable("SCANNER__EVENTS__ENABLED");
|
||||
var originalDriver = Environment.GetEnvironmentVariable("SCANNER__EVENTS__DRIVER");
|
||||
var originalDsn = Environment.GetEnvironmentVariable("SCANNER__EVENTS__DSN");
|
||||
var originalStream = Environment.GetEnvironmentVariable("SCANNER__EVENTS__STREAM");
|
||||
var originalTimeout = Environment.GetEnvironmentVariable("SCANNER__EVENTS__PUBLISHTIMEOUTSECONDS");
|
||||
var originalMax = Environment.GetEnvironmentVariable("SCANNER__EVENTS__MAXSTREAMLENGTH");
|
||||
|
||||
Environment.SetEnvironmentVariable("SCANNER__EVENTS__ENABLED", "true");
|
||||
Environment.SetEnvironmentVariable("SCANNER__EVENTS__DRIVER", "redis");
|
||||
Environment.SetEnvironmentVariable("SCANNER__EVENTS__DSN", "localhost:6379");
|
||||
Environment.SetEnvironmentVariable("SCANNER__EVENTS__STREAM", "stella.events.tests");
|
||||
Environment.SetEnvironmentVariable("SCANNER__EVENTS__PUBLISHTIMEOUTSECONDS", "1");
|
||||
Environment.SetEnvironmentVariable("SCANNER__EVENTS__MAXSTREAMLENGTH", "100");
|
||||
|
||||
try
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory(configuration =>
|
||||
{
|
||||
configuration["scanner:events:enabled"] = "true";
|
||||
configuration["scanner:events:driver"] = "redis";
|
||||
configuration["scanner:events:dsn"] = "localhost:6379";
|
||||
configuration["scanner:events:stream"] = "stella.events.tests";
|
||||
configuration["scanner:events:publishTimeoutSeconds"] = "1";
|
||||
configuration["scanner:events:maxStreamLength"] = "100";
|
||||
});
|
||||
using var scope = factory.Services.CreateScope();
|
||||
|
||||
var options = scope.ServiceProvider.GetRequiredService<IOptions<ScannerWebServiceOptions>>().Value;
|
||||
Assert.True(options.Events.Enabled);
|
||||
Assert.Equal("redis", options.Events.Driver);
|
||||
|
||||
var publisher = scope.ServiceProvider.GetRequiredService<IPlatformEventPublisher>();
|
||||
Assert.IsType<RedisPlatformEventPublisher>(publisher);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SCANNER__EVENTS__ENABLED", originalEnabled);
|
||||
Environment.SetEnvironmentVariable("SCANNER__EVENTS__DRIVER", originalDriver);
|
||||
Environment.SetEnvironmentVariable("SCANNER__EVENTS__DSN", originalDsn);
|
||||
Environment.SetEnvironmentVariable("SCANNER__EVENTS__STREAM", originalStream);
|
||||
Environment.SetEnvironmentVariable("SCANNER__EVENTS__PUBLISHTIMEOUTSECONDS", originalTimeout);
|
||||
Environment.SetEnvironmentVariable("SCANNER__EVENTS__MAXSTREAMLENGTH", originalMax);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Serialization;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
public sealed class PlatformEventSamplesTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[InlineData("scanner.event.report.ready@1.sample.json", OrchestratorEventKinds.ScannerReportReady)]
|
||||
[InlineData("scanner.event.scan.completed@1.sample.json", OrchestratorEventKinds.ScannerScanCompleted)]
|
||||
public void PlatformEventSamplesStayCanonical(string fileName, string expectedKind)
|
||||
{
|
||||
var json = LoadSample(fileName);
|
||||
var orchestratorEvent = DeserializeOrchestratorEvent(json, expectedKind);
|
||||
|
||||
Assert.NotNull(orchestratorEvent);
|
||||
Assert.Equal(expectedKind, orchestratorEvent.Kind);
|
||||
Assert.Equal(1, orchestratorEvent.Version);
|
||||
Assert.NotEqual(Guid.Empty, orchestratorEvent.EventId);
|
||||
Assert.NotNull(orchestratorEvent.Payload);
|
||||
|
||||
AssertCanonical(json, orchestratorEvent);
|
||||
AssertReportConsistency(orchestratorEvent);
|
||||
}
|
||||
|
||||
private static void AssertCanonical(string originalJson, OrchestratorEvent orchestratorEvent)
|
||||
{
|
||||
var canonicalJson = OrchestratorEventSerializer.Serialize(orchestratorEvent);
|
||||
var originalNode = JsonNode.Parse(originalJson) ?? throw new InvalidOperationException("Sample JSON must not be null.");
|
||||
var canonicalNode = JsonNode.Parse(canonicalJson) ?? throw new InvalidOperationException("Canonical JSON must not be null.");
|
||||
|
||||
if (!JsonNode.DeepEquals(originalNode, canonicalNode))
|
||||
{
|
||||
throw new Xunit.Sdk.XunitException($"Platform event sample must remain canonical.\nOriginal: {originalJson}\nCanonical: {canonicalJson}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void AssertReportConsistency(OrchestratorEvent orchestratorEvent)
|
||||
{
|
||||
switch (orchestratorEvent.Payload)
|
||||
{
|
||||
case ReportReadyEventPayload ready:
|
||||
Assert.Equal(ready.ReportId, ready.Report.ReportId);
|
||||
Assert.Equal(ready.ScanId, ready.Report.ReportId);
|
||||
AssertDsseMatchesReport(ready.Dsse, ready.Report);
|
||||
Assert.False(string.IsNullOrWhiteSpace(ready.Links.Ui));
|
||||
Assert.False(string.IsNullOrWhiteSpace(ready.Links.Report));
|
||||
Assert.False(string.IsNullOrWhiteSpace(ready.Links.Attestation));
|
||||
break;
|
||||
case ScanCompletedEventPayload completed:
|
||||
Assert.Equal(completed.ReportId, completed.Report.ReportId);
|
||||
Assert.Equal(completed.ScanId, completed.Report.ReportId);
|
||||
AssertDsseMatchesReport(completed.Dsse, completed.Report);
|
||||
Assert.NotEmpty(completed.Findings);
|
||||
Assert.False(string.IsNullOrWhiteSpace(completed.Links.Ui));
|
||||
Assert.False(string.IsNullOrWhiteSpace(completed.Links.Report));
|
||||
Assert.False(string.IsNullOrWhiteSpace(completed.Links.Attestation));
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException($"Unexpected payload type {orchestratorEvent.Payload.GetType().Name}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void AssertDsseMatchesReport(DsseEnvelopeDto? envelope, ReportDocumentDto report)
|
||||
{
|
||||
Assert.NotNull(envelope);
|
||||
var canonicalReportBytes = JsonSerializer.SerializeToUtf8Bytes(report, SerializerOptions);
|
||||
var expectedPayload = Convert.ToBase64String(canonicalReportBytes);
|
||||
Assert.Equal(expectedPayload, envelope.Payload);
|
||||
}
|
||||
|
||||
private static OrchestratorEvent DeserializeOrchestratorEvent(string json, string expectedKind)
|
||||
{
|
||||
var root = JsonNode.Parse(json)?.AsObject() ?? throw new InvalidOperationException("Sample JSON must not be null.");
|
||||
|
||||
var attributes = root["attributes"] is JsonObject attrObj && attrObj.Count > 0
|
||||
? attrObj.ToImmutableDictionary(
|
||||
pair => pair.Key,
|
||||
pair => pair.Value?.GetValue<string>() ?? string.Empty,
|
||||
StringComparer.Ordinal).ToImmutableSortedDictionary(StringComparer.Ordinal)
|
||||
: null;
|
||||
|
||||
OrchestratorEventScope? scope = null;
|
||||
if (root["scope"] is JsonObject scopeObj)
|
||||
{
|
||||
scope = new OrchestratorEventScope
|
||||
{
|
||||
Namespace = scopeObj["namespace"]?.GetValue<string>(),
|
||||
Repo = scopeObj["repo"]?.GetValue<string>() ?? string.Empty,
|
||||
Digest = scopeObj["digest"]?.GetValue<string>() ?? string.Empty,
|
||||
Component = scopeObj["component"]?.GetValue<string>(),
|
||||
Image = scopeObj["image"]?.GetValue<string>()
|
||||
};
|
||||
}
|
||||
|
||||
var payloadNode = root["payload"] ?? throw new InvalidOperationException("Payload node missing.");
|
||||
OrchestratorEventPayload payload = expectedKind switch
|
||||
{
|
||||
OrchestratorEventKinds.ScannerReportReady => payloadNode.Deserialize<ReportReadyEventPayload>(SerializerOptions)
|
||||
?? throw new InvalidOperationException("Unable to deserialize report ready payload."),
|
||||
OrchestratorEventKinds.ScannerScanCompleted => payloadNode.Deserialize<ScanCompletedEventPayload>(SerializerOptions)
|
||||
?? throw new InvalidOperationException("Unable to deserialize scan completed payload."),
|
||||
_ => throw new InvalidOperationException("Unexpected event kind.")
|
||||
};
|
||||
|
||||
return new OrchestratorEvent
|
||||
{
|
||||
EventId = Guid.Parse(root["eventId"]!.GetValue<string>()),
|
||||
Kind = root["kind"]!.GetValue<string>(),
|
||||
Version = root["version"]?.GetValue<int>() ?? 1,
|
||||
Tenant = root["tenant"]!.GetValue<string>(),
|
||||
OccurredAt = DateTimeOffset.Parse(root["occurredAt"]!.GetValue<string>(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal),
|
||||
RecordedAt = root["recordedAt"] is JsonValue recordedAtNode
|
||||
? DateTimeOffset.Parse(recordedAtNode.GetValue<string>(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal)
|
||||
: null,
|
||||
Source = root["source"]?.GetValue<string>() ?? string.Empty,
|
||||
IdempotencyKey = root["idempotencyKey"]?.GetValue<string>() ?? string.Empty,
|
||||
CorrelationId = root["correlationId"]?.GetValue<string>(),
|
||||
TraceId = root["traceId"]?.GetValue<string>(),
|
||||
SpanId = root["spanId"]?.GetValue<string>(),
|
||||
Scope = scope,
|
||||
Attributes = attributes,
|
||||
Payload = payload
|
||||
};
|
||||
}
|
||||
|
||||
private static string LoadSample(string fileName)
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, fileName);
|
||||
Assert.True(File.Exists(path), $"Sample file not found at '{path}'.");
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
public sealed class PolicyEndpointsTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
[Fact]
|
||||
public async Task PolicySchemaReturnsEmbeddedSchema()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/policy/schema");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("application/schema+json", response.Content.Headers.ContentType?.MediaType);
|
||||
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("\"$schema\"", payload);
|
||||
Assert.Contains("\"properties\"", payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PolicyDiagnosticsReturnsRecommendations()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new PolicyDiagnosticsRequestDto
|
||||
{
|
||||
Policy = new PolicyPreviewPolicyDto
|
||||
{
|
||||
Content = "version: \"1.0\"\nrules: []\n",
|
||||
Format = "yaml",
|
||||
Actor = "tester",
|
||||
Description = "empty ruleset"
|
||||
}
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/policy/diagnostics", request);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var diagnostics = await response.Content.ReadFromJsonAsync<PolicyDiagnosticsResponseDto>(SerializerOptions);
|
||||
Assert.NotNull(diagnostics);
|
||||
Assert.False(diagnostics!.Success);
|
||||
Assert.True(diagnostics.ErrorCount >= 0);
|
||||
Assert.NotEmpty(diagnostics.Recommendations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PolicyPreviewUsesProposedPolicy()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
const string policyYaml = """
|
||||
version: "1.0"
|
||||
rules:
|
||||
- name: Block Critical
|
||||
severity: [Critical]
|
||||
action: block
|
||||
""";
|
||||
|
||||
var request = new PolicyPreviewRequestDto
|
||||
{
|
||||
ImageDigest = "sha256:abc123",
|
||||
Findings = new[]
|
||||
{
|
||||
new PolicyPreviewFindingDto
|
||||
{
|
||||
Id = "finding-1",
|
||||
Severity = "Critical",
|
||||
Source = "NVD",
|
||||
Tags = new[] { "reachability:runtime" }
|
||||
}
|
||||
},
|
||||
Policy = new PolicyPreviewPolicyDto
|
||||
{
|
||||
Content = policyYaml,
|
||||
Format = "yaml",
|
||||
Actor = "preview",
|
||||
Description = "test policy"
|
||||
}
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/policy/preview", request);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var preview = await response.Content.ReadFromJsonAsync<PolicyPreviewResponseDto>(SerializerOptions);
|
||||
Assert.NotNull(preview);
|
||||
Assert.True(preview!.Success);
|
||||
Assert.Equal(1, preview.Changed);
|
||||
var diff = Assert.Single(preview.Diffs);
|
||||
Assert.Equal("finding-1", diff.Projected?.FindingId);
|
||||
Assert.Equal("Blocked", diff.Projected?.Status);
|
||||
Assert.Equal(PolicyScoringConfig.Default.Version, diff.Projected?.ConfigVersion);
|
||||
Assert.NotNull(diff.Projected?.Inputs);
|
||||
Assert.True(diff.Projected!.Inputs!.ContainsKey("severityWeight"));
|
||||
Assert.Equal("NVD", diff.Projected.SourceTrust);
|
||||
Assert.Equal("runtime", diff.Projected.Reachability);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
public sealed class ReportEventDispatcherTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_EmitsReportReadyAndScanCompleted()
|
||||
{
|
||||
var publisher = new RecordingEventPublisher();
|
||||
var dispatcher = new ReportEventDispatcher(publisher, TimeProvider.System, NullLogger<ReportEventDispatcher>.Instance);
|
||||
var cancellationToken = CancellationToken.None;
|
||||
|
||||
var request = new ReportRequestDto
|
||||
{
|
||||
ImageDigest = "sha256:feedface",
|
||||
Findings = new[]
|
||||
{
|
||||
new PolicyPreviewFindingDto
|
||||
{
|
||||
Id = "finding-1",
|
||||
Severity = "Critical",
|
||||
Repository = "acme/edge/api",
|
||||
Cve = "CVE-2024-9999",
|
||||
Tags = new[] { "reachability:runtime", "kev:CVE-2024-9999" }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var baseline = new PolicyVerdict("finding-1", PolicyVerdictStatus.Pass, ConfigVersion: "1.0");
|
||||
var projected = new PolicyVerdict(
|
||||
"finding-1",
|
||||
PolicyVerdictStatus.Blocked,
|
||||
Score: 47.5,
|
||||
ConfigVersion: "1.0",
|
||||
SourceTrust: "NVD",
|
||||
Reachability: "runtime");
|
||||
|
||||
var preview = new PolicyPreviewResponse(
|
||||
Success: true,
|
||||
PolicyDigest: "digest-123",
|
||||
RevisionId: "rev-42",
|
||||
Issues: ImmutableArray<PolicyIssue>.Empty,
|
||||
Diffs: ImmutableArray.Create(new PolicyVerdictDiff(baseline, projected)),
|
||||
ChangedCount: 1);
|
||||
|
||||
var document = new ReportDocumentDto
|
||||
{
|
||||
ReportId = "report-abc",
|
||||
ImageDigest = "sha256:feedface",
|
||||
GeneratedAt = DateTimeOffset.Parse("2025-10-19T12:34:56Z"),
|
||||
Verdict = "blocked",
|
||||
Policy = new ReportPolicyDto
|
||||
{
|
||||
RevisionId = "rev-42",
|
||||
Digest = "digest-123"
|
||||
},
|
||||
Summary = new ReportSummaryDto
|
||||
{
|
||||
Total = 1,
|
||||
Blocked = 1,
|
||||
Warned = 0,
|
||||
Ignored = 0,
|
||||
Quieted = 0
|
||||
},
|
||||
Verdicts = new[]
|
||||
{
|
||||
new PolicyPreviewVerdictDto
|
||||
{
|
||||
FindingId = "finding-1",
|
||||
Status = "Blocked",
|
||||
Score = 47.5,
|
||||
SourceTrust = "NVD",
|
||||
Reachability = "runtime"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var envelope = new DsseEnvelopeDto
|
||||
{
|
||||
PayloadType = "application/vnd.stellaops.report+json",
|
||||
Payload = Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(document, SerializerOptions)),
|
||||
Signatures = new[]
|
||||
{
|
||||
new DsseSignatureDto { KeyId = "test-key", Algorithm = "hs256", Signature = "signature-value" }
|
||||
}
|
||||
};
|
||||
|
||||
var context = new DefaultHttpContext();
|
||||
context.User = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(StellaOpsClaimTypes.Tenant, "tenant-alpha")
|
||||
}));
|
||||
context.Request.Scheme = "https";
|
||||
context.Request.Host = new HostString("scanner.example");
|
||||
|
||||
await dispatcher.PublishAsync(request, preview, document, envelope, context, cancellationToken);
|
||||
|
||||
Assert.Equal(2, publisher.Events.Count);
|
||||
|
||||
var readyEvent = Assert.Single(publisher.Events, evt => evt.Kind == OrchestratorEventKinds.ScannerReportReady);
|
||||
Assert.Equal("tenant-alpha", readyEvent.Tenant);
|
||||
Assert.Equal("scanner.event.report.ready:tenant-alpha:report-abc", readyEvent.IdempotencyKey);
|
||||
Assert.Equal("api", readyEvent.Scope?.Repo);
|
||||
Assert.Equal("acme/edge", readyEvent.Scope?.Namespace);
|
||||
Assert.Equal("sha256:feedface", readyEvent.Scope?.Digest);
|
||||
var readyPayload = Assert.IsType<ReportReadyEventPayload>(readyEvent.Payload);
|
||||
Assert.Equal("report-abc", readyPayload.ReportId);
|
||||
Assert.Equal("report-abc", readyPayload.ScanId);
|
||||
Assert.Equal("fail", readyPayload.Verdict);
|
||||
Assert.Equal(0, readyPayload.QuietedFindingCount);
|
||||
Assert.NotNull(readyPayload.Delta);
|
||||
Assert.Equal(1, readyPayload.Delta?.NewCritical);
|
||||
Assert.Contains("CVE-2024-9999", readyPayload.Delta?.Kev ?? Array.Empty<string>());
|
||||
Assert.Equal("https://scanner.example/ui/reports/report-abc", readyPayload.Links.Ui);
|
||||
Assert.Equal("https://scanner.example/api/v1/reports/report-abc", readyPayload.Links.Report);
|
||||
Assert.Equal("https://scanner.example/api/v1/policy/revisions/rev-42", readyPayload.Links.Policy);
|
||||
Assert.Equal("https://scanner.example/ui/attestations/report-abc", readyPayload.Links.Attestation);
|
||||
Assert.Equal(envelope.Payload, readyPayload.Dsse?.Payload);
|
||||
Assert.Equal("blocked", readyPayload.Report.Verdict);
|
||||
|
||||
var scanEvent = Assert.Single(publisher.Events, evt => evt.Kind == OrchestratorEventKinds.ScannerScanCompleted);
|
||||
Assert.Equal("tenant-alpha", scanEvent.Tenant);
|
||||
Assert.Equal("scanner.event.scan.completed:tenant-alpha:report-abc", scanEvent.IdempotencyKey);
|
||||
Assert.Equal("sha256:feedface", scanEvent.Scope?.Digest);
|
||||
var scanPayload = Assert.IsType<ScanCompletedEventPayload>(scanEvent.Payload);
|
||||
Assert.Equal("report-abc", scanPayload.ReportId);
|
||||
Assert.Equal("report-abc", scanPayload.ScanId);
|
||||
Assert.Equal("fail", scanPayload.Verdict);
|
||||
var finding = Assert.Single(scanPayload.Findings);
|
||||
Assert.Equal("finding-1", finding.Id);
|
||||
Assert.Equal("runtime", finding.Reachability);
|
||||
Assert.Equal("CVE-2024-9999", finding.Cve);
|
||||
Assert.Equal("https://scanner.example/api/v1/reports/report-abc", scanPayload.Links.Report);
|
||||
Assert.Equal("https://scanner.example/api/v1/policy/revisions/rev-42", scanPayload.Links.Policy);
|
||||
Assert.Equal("https://scanner.example/ui/attestations/report-abc", scanPayload.Links.Attestation);
|
||||
Assert.Equal(envelope.Payload, scanPayload.Dsse?.Payload);
|
||||
Assert.Equal("blocked", scanPayload.Report.Verdict);
|
||||
}
|
||||
|
||||
private sealed class RecordingEventPublisher : IPlatformEventPublisher
|
||||
{
|
||||
public List<OrchestratorEvent> Events { get; } = new();
|
||||
|
||||
public Task PublishAsync(OrchestratorEvent @event, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Events.Add(@event);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
public sealed class ReportSamplesTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task ReportSampleEnvelope_RemainsCanonical()
|
||||
{
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
var repoRoot = Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "..", ".."));
|
||||
var path = Path.Combine(repoRoot, "samples", "api", "reports", "report-sample.dsse.json");
|
||||
Assert.True(File.Exists(path), $"Sample file not found at {path}.");
|
||||
await using var stream = File.OpenRead(path);
|
||||
var response = await JsonSerializer.DeserializeAsync<ReportResponseDto>(stream, SerializerOptions);
|
||||
Assert.NotNull(response);
|
||||
Assert.NotNull(response!.Report);
|
||||
Assert.NotNull(response.Dsse);
|
||||
|
||||
var reportBytes = JsonSerializer.SerializeToUtf8Bytes(response.Report, SerializerOptions);
|
||||
var expectedPayload = Convert.ToBase64String(reportBytes);
|
||||
Assert.Equal(expectedPayload, response.Dsse!.Payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
public sealed class ReportsEndpointsTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task ReportsEndpointReturnsSignedEnvelope()
|
||||
{
|
||||
const string policyYaml = """
|
||||
version: "1.0"
|
||||
rules:
|
||||
- name: Block Critical
|
||||
severity: [Critical]
|
||||
action: block
|
||||
""";
|
||||
|
||||
var hmacKey = Convert.ToBase64String(Encoding.UTF8.GetBytes("scanner-report-hmac-key-2025!"));
|
||||
|
||||
using var factory = new ScannerApplicationFactory(configuration =>
|
||||
{
|
||||
configuration["scanner:signing:enabled"] = "true";
|
||||
configuration["scanner:signing:keyId"] = "scanner-report-signing";
|
||||
configuration["scanner:signing:algorithm"] = "hs256";
|
||||
configuration["scanner:signing:keyPem"] = hmacKey;
|
||||
configuration["scanner:features:enableSignedReports"] = "true";
|
||||
});
|
||||
|
||||
var store = factory.Services.GetRequiredService<PolicySnapshotStore>();
|
||||
await store.SaveAsync(
|
||||
new PolicySnapshotContent(policyYaml, PolicyDocumentFormat.Yaml, "tester", "seed", "initial"),
|
||||
CancellationToken.None);
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new ReportRequestDto
|
||||
{
|
||||
ImageDigest = "sha256:deadbeef",
|
||||
Findings = new[]
|
||||
{
|
||||
new PolicyPreviewFindingDto
|
||||
{
|
||||
Id = "finding-1",
|
||||
Severity = "Critical",
|
||||
Source = "NVD",
|
||||
Tags = new[] { "reachability:runtime" }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/reports", request);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var raw = await response.Content.ReadAsStringAsync();
|
||||
Assert.False(string.IsNullOrWhiteSpace(raw), raw);
|
||||
var payload = JsonSerializer.Deserialize<ReportResponseDto>(raw, SerializerOptions);
|
||||
Assert.NotNull(payload);
|
||||
Assert.NotNull(payload!.Report);
|
||||
Assert.NotNull(payload.Dsse);
|
||||
Assert.StartsWith("report-", payload.Report.ReportId, StringComparison.Ordinal);
|
||||
Assert.Equal("blocked", payload.Report.Verdict);
|
||||
|
||||
var dsse = payload.Dsse!;
|
||||
Assert.Equal("application/vnd.stellaops.report+json", dsse.PayloadType);
|
||||
var decodedPayload = Convert.FromBase64String(dsse.Payload);
|
||||
var canonicalPayload = JsonSerializer.SerializeToUtf8Bytes(payload.Report, SerializerOptions);
|
||||
var expectedBase64 = Convert.ToBase64String(canonicalPayload);
|
||||
Assert.Equal(expectedBase64, dsse.Payload);
|
||||
|
||||
var reportVerdict = Assert.Single(payload.Report.Verdicts);
|
||||
Assert.Equal("NVD", reportVerdict.SourceTrust);
|
||||
Assert.Equal("runtime", reportVerdict.Reachability);
|
||||
Assert.NotNull(reportVerdict.Inputs);
|
||||
Assert.True(reportVerdict.Inputs!.ContainsKey("severityWeight"));
|
||||
Assert.Equal(PolicyScoringConfig.Default.Version, reportVerdict.ConfigVersion);
|
||||
|
||||
var signature = Assert.Single(dsse.Signatures);
|
||||
Assert.Equal("scanner-report-signing", signature.KeyId);
|
||||
Assert.Equal("hs256", signature.Algorithm, ignoreCase: true);
|
||||
|
||||
using var hmac = new System.Security.Cryptography.HMACSHA256(Convert.FromBase64String(hmacKey));
|
||||
var expectedSig = Convert.ToBase64String(hmac.ComputeHash(decodedPayload));
|
||||
var actualSig = signature.Signature;
|
||||
Assert.True(expectedSig == actualSig, $"expected:{expectedSig}, actual:{actualSig}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReportsEndpointValidatesDigest()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new ReportRequestDto
|
||||
{
|
||||
ImageDigest = "",
|
||||
Findings = Array.Empty<PolicyPreviewFindingDto>()
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/reports", request);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReportsEndpointReturnsServiceUnavailableWhenPolicyMissing()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new ReportRequestDto
|
||||
{
|
||||
ImageDigest = "sha256:feedface",
|
||||
Findings = new[]
|
||||
{
|
||||
new PolicyPreviewFindingDto { Id = "finding-1", Severity = "High" }
|
||||
}
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/reports", request);
|
||||
Assert.Equal((HttpStatusCode)StatusCodes.Status503ServiceUnavailable, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReportsEndpointPublishesPlatformEvents()
|
||||
{
|
||||
const string policyYaml = """
|
||||
version: "1.0"
|
||||
rules:
|
||||
- name: Block Critical
|
||||
severity: [Critical]
|
||||
action: block
|
||||
""";
|
||||
|
||||
using var factory = new ScannerApplicationFactory(
|
||||
configuration =>
|
||||
{
|
||||
configuration["scanner:signing:enabled"] = "true";
|
||||
configuration["scanner:signing:keyId"] = "scanner-report-signing";
|
||||
configuration["scanner:signing:algorithm"] = "hs256";
|
||||
configuration["scanner:signing:keyPem"] = Convert.ToBase64String(Encoding.UTF8.GetBytes("scanner-report-hmac-key-events!"));
|
||||
configuration["scanner:features:enableSignedReports"] = "true";
|
||||
configuration["scanner:events:enabled"] = "true";
|
||||
configuration["scanner:events:driver"] = "redis";
|
||||
configuration["scanner:events:dsn"] = "redis://tests";
|
||||
configuration["scanner:events:stream"] = "stella.events.tests";
|
||||
configuration["scanner:events:publishTimeoutSeconds"] = "5";
|
||||
configuration["scanner:events:maxStreamLength"] = "100";
|
||||
},
|
||||
services =>
|
||||
{
|
||||
services.RemoveAll<IPlatformEventPublisher>();
|
||||
services.AddSingleton<RecordingPlatformEventPublisher>();
|
||||
services.AddSingleton<IPlatformEventPublisher>(sp => sp.GetRequiredService<RecordingPlatformEventPublisher>());
|
||||
});
|
||||
|
||||
var store = factory.Services.GetRequiredService<PolicySnapshotStore>();
|
||||
var saveResult = await store.SaveAsync(
|
||||
new PolicySnapshotContent(policyYaml, PolicyDocumentFormat.Yaml, "tester", "seed", "initial"),
|
||||
CancellationToken.None);
|
||||
var revisionId = saveResult.Snapshot?.RevisionId ?? string.Empty;
|
||||
|
||||
var recorder = factory.Services.GetRequiredService<RecordingPlatformEventPublisher>();
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new ReportRequestDto
|
||||
{
|
||||
ImageDigest = "sha256:cafebabe",
|
||||
Findings = new[]
|
||||
{
|
||||
new PolicyPreviewFindingDto
|
||||
{
|
||||
Id = "finding-42",
|
||||
Severity = "Critical",
|
||||
Repository = "acme/edge/api",
|
||||
Tags = new[] { "reachability:runtime", "kev:CVE-2024-1234" }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/reports", request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
Assert.Equal(2, recorder.Events.Count);
|
||||
var ready = recorder.Events.Single(evt => evt.Kind == OrchestratorEventKinds.ScannerReportReady);
|
||||
var completed = recorder.Events.Single(evt => evt.Kind == OrchestratorEventKinds.ScannerScanCompleted);
|
||||
|
||||
Assert.Equal("default", ready.Tenant);
|
||||
Assert.Equal("default", completed.Tenant);
|
||||
Assert.NotEqual(Guid.Empty, ready.EventId);
|
||||
Assert.NotEqual(Guid.Empty, completed.EventId);
|
||||
|
||||
var readyPayload = Assert.IsType<ReportReadyEventPayload>(ready.Payload);
|
||||
var completedPayload = Assert.IsType<ScanCompletedEventPayload>(completed.Payload);
|
||||
Assert.Equal(readyPayload.ReportId, completedPayload.ReportId);
|
||||
Assert.Equal("sha256:cafebabe", completedPayload.ImageDigest);
|
||||
|
||||
Assert.Equal("fail", readyPayload.Verdict);
|
||||
Assert.Equal("acme/edge", ready.Scope?.Namespace);
|
||||
Assert.Equal("api", ready.Scope?.Repo);
|
||||
Assert.Equal("sha256:cafebabe", ready.Scope?.Digest);
|
||||
Assert.NotNull(readyPayload.Dsse);
|
||||
Assert.Equal(readyPayload.ReportId, readyPayload.Report.ReportId);
|
||||
Assert.Equal("http://localhost/ui/reports/" + readyPayload.ReportId, readyPayload.Links.Ui);
|
||||
Assert.Equal("http://localhost/api/v1/reports/" + readyPayload.ReportId, readyPayload.Links.Report);
|
||||
if (!string.IsNullOrWhiteSpace(revisionId))
|
||||
{
|
||||
Assert.Equal("http://localhost/api/v1/policy/revisions/" + revisionId, readyPayload.Links.Policy);
|
||||
}
|
||||
Assert.Equal("http://localhost/ui/attestations/" + readyPayload.ReportId, readyPayload.Links.Attestation);
|
||||
|
||||
Assert.Equal("fail", completedPayload.Verdict);
|
||||
Assert.NotEmpty(completedPayload.Findings);
|
||||
Assert.Equal("finding-42", completedPayload.Findings[0].Id);
|
||||
Assert.Equal("http://localhost/api/v1/reports/" + completedPayload.ReportId, completedPayload.Links.Report);
|
||||
if (!string.IsNullOrWhiteSpace(revisionId))
|
||||
{
|
||||
Assert.Equal("http://localhost/api/v1/policy/revisions/" + revisionId, completedPayload.Links.Policy);
|
||||
}
|
||||
Assert.Equal("http://localhost/ui/attestations/" + completedPayload.ReportId, completedPayload.Links.Attestation);
|
||||
}
|
||||
|
||||
private sealed class RecordingPlatformEventPublisher : IPlatformEventPublisher
|
||||
{
|
||||
public List<OrchestratorEvent> Events { get; } = new();
|
||||
|
||||
public Task PublishAsync(OrchestratorEvent @event, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Events.Add(@event);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Scanner.Storage.Catalog;
|
||||
using StellaOps.Scanner.Storage.Mongo;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
public sealed class RuntimeEndpointsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RuntimeEventsEndpointPersistsEvents()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new RuntimeEventsIngestRequestDto
|
||||
{
|
||||
BatchId = "batch-1",
|
||||
Events = new[]
|
||||
{
|
||||
CreateEnvelope("evt-001", buildId: "ABCDEF1234567890ABCDEF1234567890ABCDEF12"),
|
||||
CreateEnvelope("evt-002", buildId: "abcdef1234567890abcdef1234567890abcdef12")
|
||||
}
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/runtime/events", request);
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<RuntimeEventsIngestResponseDto>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal(2, payload!.Accepted);
|
||||
Assert.Equal(0, payload.Duplicates);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var collections = scope.ServiceProvider.GetRequiredService<MongoCollectionProvider>();
|
||||
var stored = await collections.RuntimeEvents.Find(FilterDefinition<RuntimeEventDocument>.Empty).ToListAsync();
|
||||
Assert.Equal(2, stored.Count);
|
||||
Assert.Contains(stored, doc => doc.EventId == "evt-001");
|
||||
Assert.All(stored, doc =>
|
||||
{
|
||||
Assert.Equal("tenant-alpha", doc.Tenant);
|
||||
Assert.True(doc.ExpiresAt > doc.ReceivedAt);
|
||||
Assert.Equal("sha256:deadbeef", doc.ImageDigest);
|
||||
Assert.Equal("abcdef1234567890abcdef1234567890abcdef12", doc.BuildId);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RuntimeEventsEndpointRejectsUnsupportedSchema()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var envelope = CreateEnvelope("evt-100", schemaVersion: "zastava.runtime.event@v2.0");
|
||||
|
||||
var request = new RuntimeEventsIngestRequestDto
|
||||
{
|
||||
Events = new[] { envelope }
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/runtime/events", request);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RuntimeEventsEndpointEnforcesRateLimit()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory(configuration =>
|
||||
{
|
||||
configuration["scanner:runtime:perNodeBurst"] = "1";
|
||||
configuration["scanner:runtime:perNodeEventsPerSecond"] = "1";
|
||||
configuration["scanner:runtime:perTenantBurst"] = "1";
|
||||
configuration["scanner:runtime:perTenantEventsPerSecond"] = "1";
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new RuntimeEventsIngestRequestDto
|
||||
{
|
||||
Events = new[]
|
||||
{
|
||||
CreateEnvelope("evt-500"),
|
||||
CreateEnvelope("evt-501")
|
||||
}
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/runtime/events", request);
|
||||
Assert.Equal((HttpStatusCode)StatusCodes.Status429TooManyRequests, response.StatusCode);
|
||||
Assert.NotNull(response.Headers.RetryAfter);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var collections = scope.ServiceProvider.GetRequiredService<MongoCollectionProvider>();
|
||||
var count = await collections.RuntimeEvents.CountDocumentsAsync(FilterDefinition<RuntimeEventDocument>.Empty);
|
||||
Assert.Equal(0, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RuntimePolicyEndpointReturnsDecisions()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory(configuration =>
|
||||
{
|
||||
configuration["scanner:runtime:policyCacheTtlSeconds"] = "600";
|
||||
});
|
||||
|
||||
const string imageDigest = "sha256:deadbeef";
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var collections = scope.ServiceProvider.GetRequiredService<MongoCollectionProvider>();
|
||||
var policyStore = scope.ServiceProvider.GetRequiredService<PolicySnapshotStore>();
|
||||
|
||||
const string policyYaml = """
|
||||
version: "1.0"
|
||||
rules:
|
||||
- name: Block Critical
|
||||
severity: [Critical]
|
||||
action: block
|
||||
""";
|
||||
var saveResult = await policyStore.SaveAsync(
|
||||
new PolicySnapshotContent(policyYaml, PolicyDocumentFormat.Yaml, "tester", "tests", "seed"),
|
||||
CancellationToken.None);
|
||||
Assert.True(saveResult.Success);
|
||||
|
||||
var snapshot = await policyStore.GetLatestAsync(CancellationToken.None);
|
||||
Assert.NotNull(snapshot);
|
||||
|
||||
var sbomArtifactId = CatalogIdFactory.CreateArtifactId(ArtifactDocumentType.ImageBom, "sha256:sbomdigest");
|
||||
var attestationArtifactId = CatalogIdFactory.CreateArtifactId(ArtifactDocumentType.Attestation, "sha256:attdigest");
|
||||
|
||||
await collections.Artifacts.InsertManyAsync(new[]
|
||||
{
|
||||
new ArtifactDocument
|
||||
{
|
||||
Id = sbomArtifactId,
|
||||
Type = ArtifactDocumentType.ImageBom,
|
||||
Format = ArtifactDocumentFormat.CycloneDxJson,
|
||||
MediaType = "application/json",
|
||||
BytesSha256 = "sha256:sbomdigest",
|
||||
RefCount = 1,
|
||||
CreatedAtUtc = DateTime.UtcNow,
|
||||
UpdatedAtUtc = DateTime.UtcNow
|
||||
},
|
||||
new ArtifactDocument
|
||||
{
|
||||
Id = attestationArtifactId,
|
||||
Type = ArtifactDocumentType.Attestation,
|
||||
Format = ArtifactDocumentFormat.DsseJson,
|
||||
MediaType = "application/vnd.dsse.envelope+json",
|
||||
BytesSha256 = "sha256:attdigest",
|
||||
RefCount = 1,
|
||||
CreatedAtUtc = DateTime.UtcNow,
|
||||
UpdatedAtUtc = DateTime.UtcNow,
|
||||
Rekor = new RekorReference { Uuid = "rekor-uuid", Url = "https://rekor.example/uuid/rekor-uuid", Index = 7 }
|
||||
}
|
||||
});
|
||||
|
||||
await collections.Links.InsertManyAsync(new[]
|
||||
{
|
||||
new LinkDocument
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
FromType = LinkSourceType.Image,
|
||||
FromDigest = imageDigest,
|
||||
ArtifactId = sbomArtifactId,
|
||||
CreatedAtUtc = DateTime.UtcNow
|
||||
},
|
||||
new LinkDocument
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
FromType = LinkSourceType.Image,
|
||||
FromDigest = imageDigest,
|
||||
ArtifactId = attestationArtifactId,
|
||||
CreatedAtUtc = DateTime.UtcNow
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var ingestRequest = new RuntimeEventsIngestRequestDto
|
||||
{
|
||||
Events = new[]
|
||||
{
|
||||
CreateEnvelope("evt-210", imageDigest: imageDigest, buildId: "1122aabbccddeeff00112233445566778899aabb"),
|
||||
CreateEnvelope("evt-211", imageDigest: imageDigest, buildId: "1122AABBCCDDEEFF00112233445566778899AABB")
|
||||
}
|
||||
};
|
||||
var ingestResponse = await client.PostAsJsonAsync("/api/v1/runtime/events", ingestRequest);
|
||||
Assert.Equal(HttpStatusCode.Accepted, ingestResponse.StatusCode);
|
||||
|
||||
var request = new RuntimePolicyRequestDto
|
||||
{
|
||||
Namespace = "payments",
|
||||
Images = new[] { imageDigest, imageDigest },
|
||||
Labels = new Dictionary<string, string> { ["app"] = "api" }
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/policy/runtime", request);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var raw = await response.Content.ReadAsStringAsync();
|
||||
Assert.False(string.IsNullOrWhiteSpace(raw), "Runtime policy response body was empty.");
|
||||
var payload = JsonSerializer.Deserialize<RuntimePolicyResponseDto>(raw);
|
||||
Assert.True(payload is not null, $"Runtime policy response: {raw}");
|
||||
Assert.Equal(600, payload!.TtlSeconds);
|
||||
Assert.NotNull(payload.PolicyRevision);
|
||||
Assert.True(payload.ExpiresAtUtc > DateTimeOffset.UtcNow);
|
||||
|
||||
var decision = payload.Results[imageDigest];
|
||||
Assert.Equal("pass", decision.PolicyVerdict);
|
||||
Assert.True(decision.Signed);
|
||||
Assert.True(decision.HasSbomReferrers);
|
||||
Assert.True(decision.HasSbomLegacy);
|
||||
Assert.Empty(decision.Reasons);
|
||||
Assert.NotNull(decision.Rekor);
|
||||
Assert.Equal("rekor-uuid", decision.Rekor!.Uuid);
|
||||
Assert.True(decision.Rekor.Verified);
|
||||
Assert.NotNull(decision.Confidence);
|
||||
Assert.InRange(decision.Confidence!.Value, 0.0, 1.0);
|
||||
Assert.False(decision.Quieted.GetValueOrDefault());
|
||||
Assert.Null(decision.QuietedBy);
|
||||
Assert.NotNull(decision.BuildIds);
|
||||
Assert.Contains("1122aabbccddeeff00112233445566778899aabb", decision.BuildIds!);
|
||||
var metadataString = decision.Metadata;
|
||||
Console.WriteLine($"Runtime policy metadata: {metadataString ?? "<null>"}");
|
||||
Assert.False(string.IsNullOrWhiteSpace(metadataString));
|
||||
using var metadataDocument = JsonDocument.Parse(decision.Metadata!);
|
||||
Assert.True(metadataDocument.RootElement.TryGetProperty("heuristics", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RuntimePolicyEndpointFlagsUnsignedAndMissingSbom()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
const string imageDigest = "sha256:feedface";
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var collections = scope.ServiceProvider.GetRequiredService<MongoCollectionProvider>();
|
||||
var policyStore = scope.ServiceProvider.GetRequiredService<PolicySnapshotStore>();
|
||||
|
||||
const string policyYaml = """
|
||||
version: "1.0"
|
||||
rules: []
|
||||
""";
|
||||
await policyStore.SaveAsync(
|
||||
new PolicySnapshotContent(policyYaml, PolicyDocumentFormat.Yaml, "tester", "tests", "baseline"),
|
||||
CancellationToken.None);
|
||||
|
||||
// Intentionally skip artifacts/links to simulate missing metadata.
|
||||
await collections.RuntimeEvents.DeleteManyAsync(Builders<RuntimeEventDocument>.Filter.Empty);
|
||||
}
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/policy/runtime", new RuntimePolicyRequestDto
|
||||
{
|
||||
Namespace = "payments",
|
||||
Images = new[] { imageDigest }
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var payload = await response.Content.ReadFromJsonAsync<RuntimePolicyResponseDto>();
|
||||
Assert.NotNull(payload);
|
||||
var decision = payload!.Results[imageDigest];
|
||||
|
||||
Assert.Equal("fail", decision.PolicyVerdict);
|
||||
Assert.False(decision.Signed);
|
||||
Assert.False(decision.HasSbomReferrers);
|
||||
Assert.Contains("image.metadata.missing", decision.Reasons);
|
||||
Assert.Contains("unsigned", decision.Reasons);
|
||||
Assert.Contains("missing SBOM", decision.Reasons);
|
||||
Assert.NotNull(decision.Confidence);
|
||||
Assert.InRange(decision.Confidence!.Value, 0.0, 1.0);
|
||||
if (!string.IsNullOrWhiteSpace(decision.Metadata))
|
||||
{
|
||||
using var failureMetadata = JsonDocument.Parse(decision.Metadata!);
|
||||
if (failureMetadata.RootElement.TryGetProperty("heuristics", out var heuristicsElement))
|
||||
{
|
||||
var heuristics = heuristicsElement.EnumerateArray().Select(item => item.GetString()).ToArray();
|
||||
Assert.Contains("image.metadata.missing", heuristics);
|
||||
Assert.Contains("unsigned", heuristics);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RuntimePolicyEndpointValidatesRequest()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new RuntimePolicyRequestDto
|
||||
{
|
||||
Images = Array.Empty<string>()
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/policy/runtime", request);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
private static RuntimeEventEnvelope CreateEnvelope(
|
||||
string eventId,
|
||||
string? schemaVersion = null,
|
||||
string? imageDigest = null,
|
||||
string? buildId = null)
|
||||
{
|
||||
var digest = string.IsNullOrWhiteSpace(imageDigest) ? "sha256:deadbeef" : imageDigest;
|
||||
var runtimeEvent = new RuntimeEvent
|
||||
{
|
||||
EventId = eventId,
|
||||
When = DateTimeOffset.UtcNow,
|
||||
Kind = RuntimeEventKind.ContainerStart,
|
||||
Tenant = "tenant-alpha",
|
||||
Node = "node-a",
|
||||
Runtime = new RuntimeEngine
|
||||
{
|
||||
Engine = "containerd",
|
||||
Version = "1.7.0"
|
||||
},
|
||||
Workload = new RuntimeWorkload
|
||||
{
|
||||
Platform = "kubernetes",
|
||||
Namespace = "default",
|
||||
Pod = "api-123",
|
||||
Container = "api",
|
||||
ContainerId = "containerd://abc",
|
||||
ImageRef = $"ghcr.io/example/api@{digest}"
|
||||
},
|
||||
Delta = new RuntimeDelta
|
||||
{
|
||||
BaselineImageDigest = digest
|
||||
},
|
||||
Process = new RuntimeProcess
|
||||
{
|
||||
Pid = 123,
|
||||
Entrypoint = new[] { "/bin/start" },
|
||||
EntryTrace = Array.Empty<RuntimeEntryTrace>(),
|
||||
BuildId = buildId
|
||||
}
|
||||
};
|
||||
|
||||
if (schemaVersion is null)
|
||||
{
|
||||
return RuntimeEventEnvelope.Create(runtimeEvent, ZastavaContractVersions.RuntimeEvent);
|
||||
}
|
||||
|
||||
return new RuntimeEventEnvelope
|
||||
{
|
||||
SchemaVersion = schemaVersion,
|
||||
Event = runtimeEvent
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Mongo2Go;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
internal sealed class ScannerApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly MongoDbRunner mongoRunner;
|
||||
private readonly Dictionary<string, string?> configuration = new()
|
||||
{
|
||||
["scanner:storage:driver"] = "mongo",
|
||||
["scanner:storage:dsn"] = string.Empty,
|
||||
["scanner:queue:driver"] = "redis",
|
||||
["scanner:queue:dsn"] = "redis://localhost:6379",
|
||||
["scanner:artifactStore:driver"] = "rustfs",
|
||||
["scanner:artifactStore:endpoint"] = "https://rustfs.local/api/v1/",
|
||||
["scanner:artifactStore:accessKey"] = "test-access",
|
||||
["scanner:artifactStore:secretKey"] = "test-secret",
|
||||
["scanner:artifactStore:bucket"] = "scanner-artifacts",
|
||||
["scanner:artifactStore:timeoutSeconds"] = "30",
|
||||
["scanner:telemetry:minimumLogLevel"] = "Information",
|
||||
["scanner:telemetry:enableRequestLogging"] = "false",
|
||||
["scanner:events:enabled"] = "false",
|
||||
["scanner:features:enableSignedReports"] = "false"
|
||||
};
|
||||
|
||||
private readonly Action<IDictionary<string, string?>>? configureConfiguration;
|
||||
private readonly Action<IServiceCollection>? configureServices;
|
||||
|
||||
public ScannerApplicationFactory(
|
||||
Action<IDictionary<string, string?>>? configureConfiguration = null,
|
||||
Action<IServiceCollection>? configureServices = null)
|
||||
{
|
||||
EnsureMongo2GoEnvironment();
|
||||
mongoRunner = MongoDbRunner.Start(singleNodeReplSet: true);
|
||||
configuration["scanner:storage:dsn"] = mongoRunner.ConnectionString;
|
||||
this.configureConfiguration = configureConfiguration;
|
||||
this.configureServices = configureServices;
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
configureConfiguration?.Invoke(configuration);
|
||||
|
||||
builder.UseEnvironment("Testing");
|
||||
|
||||
Environment.SetEnvironmentVariable("SCANNER__AUTHORITY__ENABLED", null);
|
||||
Environment.SetEnvironmentVariable("SCANNER__AUTHORITY__ALLOWANONYMOUSFALLBACK", null);
|
||||
Environment.SetEnvironmentVariable("SCANNER__AUTHORITY__ISSUER", null);
|
||||
Environment.SetEnvironmentVariable("SCANNER__AUTHORITY__AUDIENCES__0", null);
|
||||
Environment.SetEnvironmentVariable("SCANNER__AUTHORITY__CLIENTID", null);
|
||||
Environment.SetEnvironmentVariable("SCANNER__AUTHORITY__CLIENTSECRET", null);
|
||||
Environment.SetEnvironmentVariable("SCANNER__STORAGE__DSN", configuration["scanner:storage:dsn"]);
|
||||
Environment.SetEnvironmentVariable("SCANNER__QUEUE__DSN", configuration["scanner:queue:dsn"]);
|
||||
Environment.SetEnvironmentVariable("SCANNER__ARTIFACTSTORE__ENDPOINT", configuration["scanner:artifactStore:endpoint"]);
|
||||
Environment.SetEnvironmentVariable("SCANNER__ARTIFACTSTORE__ACCESSKEY", configuration["scanner:artifactStore:accessKey"]);
|
||||
Environment.SetEnvironmentVariable("SCANNER__ARTIFACTSTORE__SECRETKEY", configuration["scanner:artifactStore:secretKey"]);
|
||||
if (configuration.TryGetValue("scanner:events:enabled", out var eventsEnabled))
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SCANNER__EVENTS__ENABLED", eventsEnabled);
|
||||
}
|
||||
|
||||
if (configuration.TryGetValue("scanner:authority:enabled", out var authorityEnabled))
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SCANNER__AUTHORITY__ENABLED", authorityEnabled);
|
||||
}
|
||||
|
||||
if (configuration.TryGetValue("scanner:authority:allowAnonymousFallback", out var allowAnonymous))
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SCANNER__AUTHORITY__ALLOWANONYMOUSFALLBACK", allowAnonymous);
|
||||
}
|
||||
|
||||
if (configuration.TryGetValue("scanner:authority:issuer", out var authorityIssuer))
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SCANNER__AUTHORITY__ISSUER", authorityIssuer);
|
||||
}
|
||||
|
||||
if (configuration.TryGetValue("scanner:authority:audiences:0", out var primaryAudience))
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SCANNER__AUTHORITY__AUDIENCES__0", primaryAudience);
|
||||
}
|
||||
|
||||
if (configuration.TryGetValue("scanner:authority:clientId", out var clientId))
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SCANNER__AUTHORITY__CLIENTID", clientId);
|
||||
}
|
||||
|
||||
if (configuration.TryGetValue("scanner:authority:clientSecret", out var clientSecret))
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SCANNER__AUTHORITY__CLIENTSECRET", clientSecret);
|
||||
}
|
||||
|
||||
builder.ConfigureAppConfiguration((_, configBuilder) =>
|
||||
{
|
||||
configBuilder.AddInMemoryCollection(configuration);
|
||||
});
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
configureServices?.Invoke(services);
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
|
||||
if (disposing)
|
||||
{
|
||||
mongoRunner.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureMongo2GoEnvironment()
|
||||
{
|
||||
if (!OperatingSystem.IsLinux())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var libraryPath = ResolveOpenSslLibraryPath();
|
||||
if (libraryPath is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var existing = Environment.GetEnvironmentVariable("LD_LIBRARY_PATH");
|
||||
if (string.IsNullOrEmpty(existing))
|
||||
{
|
||||
Environment.SetEnvironmentVariable("LD_LIBRARY_PATH", libraryPath);
|
||||
return;
|
||||
}
|
||||
|
||||
var segments = existing.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (Array.IndexOf(segments, libraryPath) < 0)
|
||||
{
|
||||
Environment.SetEnvironmentVariable("LD_LIBRARY_PATH", string.Join(':', new[] { libraryPath }.Concat(segments)));
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveOpenSslLibraryPath()
|
||||
{
|
||||
var current = AppContext.BaseDirectory;
|
||||
while (!string.IsNullOrEmpty(current))
|
||||
{
|
||||
var candidate = Path.Combine(current, "tools", "openssl", "linux-x64");
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
var parent = Directory.GetParent(current);
|
||||
if (parent is null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
current = parent.FullName;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
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 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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private sealed record ProgressEnvelope(
|
||||
string ScanId,
|
||||
int Sequence,
|
||||
string State,
|
||||
string? Message,
|
||||
DateTimeOffset Timestamp,
|
||||
string CorrelationId,
|
||||
Dictionary<string, JsonElement> Data);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>StellaOps.Scanner.WebService.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Scanner.WebService/StellaOps.Scanner.WebService.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="..\..\docs\events\samples\scanner.event.report.ready@1.sample.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Include="..\..\docs\events\samples\scanner.event.scan.completed@1.sample.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user