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().WithOverrides(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(); 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(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() }; 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().WithOverrides( 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(); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); }); var store = factory.Services.GetRequiredService(); 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(); 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(ready.Payload); var completedPayload = Assert.IsType(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.Report?.Ui); Assert.Equal("http://localhost/api/v1/reports/" + readyPayload.ReportId, readyPayload.Links.Report?.Api); if (!string.IsNullOrWhiteSpace(revisionId)) { Assert.Equal("http://localhost/ui/policy/revisions/" + revisionId, readyPayload.Links.Policy?.Ui); Assert.Equal("http://localhost/api/v1/policy/revisions/" + revisionId, readyPayload.Links.Policy?.Api); } Assert.Equal("http://localhost/ui/attestations/" + readyPayload.ReportId, readyPayload.Links.Attestation?.Ui); Assert.Equal("http://localhost/api/v1/reports/" + readyPayload.ReportId + "/attestation", readyPayload.Links.Attestation?.Api); 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?.Api); Assert.Equal("http://localhost/ui/reports/" + completedPayload.ReportId, completedPayload.Links.Report?.Ui); if (!string.IsNullOrWhiteSpace(revisionId)) { Assert.Equal("http://localhost/ui/policy/revisions/" + revisionId, completedPayload.Links.Policy?.Ui); Assert.Equal("http://localhost/api/v1/policy/revisions/" + revisionId, completedPayload.Links.Policy?.Api); } Assert.Equal("http://localhost/ui/attestations/" + completedPayload.ReportId, completedPayload.Links.Attestation?.Ui); Assert.Equal("http://localhost/api/v1/reports/" + completedPayload.ReportId + "/attestation", completedPayload.Links.Attestation?.Api); } private sealed class RecordingPlatformEventPublisher : IPlatformEventPublisher { public List Events { get; } = new(); public Task PublishAsync(OrchestratorEvent @event, CancellationToken cancellationToken = default) { Events.Add(@event); return Task.CompletedTask; } } }