using System; using Xunit; 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 Microsoft.Extensions.Options; using StellaOps.Auth.Abstractions; using StellaOps.Determinism; using StellaOps.Policy; using StellaOps.Scanner.Storage.Models; using StellaOps.Scanner.Storage.Services; using StellaOps.Scanner.WebService.Contracts; using StellaOps.Scanner.WebService.Options; using StellaOps.Scanner.WebService.Services; using StellaOps.TestKit; namespace StellaOps.Scanner.WebService.Tests; public sealed class ReportEventDispatcherTests { private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; [Trait("Category", TestCategories.Unit)] [Fact] public async Task PublishAsync_EmitsReportReadyAndScanCompleted() { var publisher = new RecordingEventPublisher(); var tracker = new RecordingClassificationChangeTracker(); var dispatcher = new ReportEventDispatcher( publisher, tracker, Microsoft.Extensions.Options.Options.Create(new ScannerWebServiceOptions()), new SequentialGuidProvider(), TimeProvider.System, NullLogger.Instance); var cancellationToken = TestContext.Current.CancellationToken; 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.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(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()); Assert.Equal("https://scanner.example/ui/reports/report-abc", readyPayload.Links.Report?.Ui); Assert.Equal("https://scanner.example/api/v1/reports/report-abc", readyPayload.Links.Report?.Api); Assert.Equal("https://scanner.example/ui/policy/revisions/rev-42", readyPayload.Links.Policy?.Ui); Assert.Equal("https://scanner.example/api/v1/policy/revisions/rev-42", readyPayload.Links.Policy?.Api); Assert.Equal("https://scanner.example/ui/attestations/report-abc", readyPayload.Links.Attestation?.Ui); Assert.Equal("https://scanner.example/api/v1/reports/report-abc/attestation", readyPayload.Links.Attestation?.Api); 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(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?.Api); Assert.Equal("https://scanner.example/ui/reports/report-abc", scanPayload.Links.Report?.Ui); Assert.Equal("https://scanner.example/ui/policy/revisions/rev-42", scanPayload.Links.Policy?.Ui); Assert.Equal("https://scanner.example/api/v1/policy/revisions/rev-42", scanPayload.Links.Policy?.Api); Assert.Equal("https://scanner.example/ui/attestations/report-abc", scanPayload.Links.Attestation?.Ui); Assert.Equal("https://scanner.example/api/v1/reports/report-abc/attestation", scanPayload.Links.Attestation?.Api); Assert.Equal(envelope.Payload, scanPayload.Dsse?.Payload); Assert.Equal("blocked", scanPayload.Report.Verdict); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task PublishAsync_RecordsFnDriftClassificationChanges() { var publisher = new RecordingEventPublisher(); var tracker = new RecordingClassificationChangeTracker(); var dispatcher = new ReportEventDispatcher( publisher, tracker, Microsoft.Extensions.Options.Options.Create(new ScannerWebServiceOptions()), new SequentialGuidProvider(), TimeProvider.System, NullLogger.Instance); var cancellationToken = TestContext.Current.CancellationToken; var request = new ReportRequestDto { ImageDigest = "sha256:feedface", Findings = new[] { new PolicyPreviewFindingDto { Id = "finding-1", Severity = "Critical", Repository = "acme/edge/api", Cve = "CVE-2024-9999", Purl = "pkg:nuget/Acme.Edge.Api@1.2.3", Tags = new[] { "reachability:runtime" } } } }; 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.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 } }; var context = new DefaultHttpContext(); context.User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(StellaOpsClaimTypes.Tenant, "tenant-alpha") })); await dispatcher.PublishAsync(request, preview, document, envelope: null, context, cancellationToken); var change = Assert.Single(tracker.Changes); Assert.Equal("sha256:feedface", change.ArtifactDigest); Assert.Equal("CVE-2024-9999", change.VulnId); Assert.Equal("pkg:nuget/Acme.Edge.Api@1.2.3", change.PackagePurl); Assert.Equal(ClassificationStatus.Unaffected, change.PreviousStatus); Assert.Equal(ClassificationStatus.Affected, change.NewStatus); Assert.Equal(DriftCause.ReachabilityDelta, change.Cause); Assert.Equal(document.GeneratedAt, change.ChangedAt); Assert.NotEqual(Guid.Empty, change.TenantId); Assert.NotEqual(Guid.Empty, change.ExecutionId); Assert.NotEqual(Guid.Empty, change.ManifestId); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task PublishAsync_DoesNotFailWhenFnDriftTrackingThrows() { var publisher = new RecordingEventPublisher(); var tracker = new RecordingClassificationChangeTracker { ThrowOnTrack = true }; var dispatcher = new ReportEventDispatcher( publisher, tracker, Microsoft.Extensions.Options.Options.Create(new ScannerWebServiceOptions()), new SequentialGuidProvider(), TimeProvider.System, NullLogger.Instance); var cancellationToken = TestContext.Current.CancellationToken; var request = new ReportRequestDto { ImageDigest = "sha256:feedface", Findings = new[] { new PolicyPreviewFindingDto { Id = "finding-1", Severity = "Critical", Repository = "acme/edge/api", Cve = "CVE-2024-9999", Purl = "pkg:nuget/Acme.Edge.Api@1.2.3" } } }; var baseline = new PolicyVerdict("finding-1", PolicyVerdictStatus.Pass, ConfigVersion: "1.0"); var projected = new PolicyVerdict("finding-1", PolicyVerdictStatus.Blocked, ConfigVersion: "1.0"); var preview = new PolicyPreviewResponse( Success: true, PolicyDigest: "digest-123", RevisionId: "rev-42", Issues: ImmutableArray.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(), Summary = new ReportSummaryDto() }; var context = new DefaultHttpContext(); context.User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(StellaOpsClaimTypes.Tenant, "tenant-alpha") })); await dispatcher.PublishAsync(request, preview, document, envelope: null, context, cancellationToken); Assert.Equal(2, publisher.Events.Count); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task PublishAsync_HonoursConfiguredConsoleAndApiSegments() { var options = Microsoft.Extensions.Options.Options.Create(new ScannerWebServiceOptions { Api = new ScannerWebServiceOptions.ApiOptions { BasePath = "/custom-api", ReportsSegment = "reports-view", PolicySegment = "policy-hub" }, Console = new ScannerWebServiceOptions.ConsoleOptions { BasePath = "/console", ReportsSegment = "insights", PolicySegment = "policy-center", AttestationsSegment = "evidence" } }); var publisher = new RecordingEventPublisher(); var tracker = new RecordingClassificationChangeTracker(); var dispatcher = new ReportEventDispatcher( publisher, tracker, options, new SequentialGuidProvider(), TimeProvider.System, NullLogger.Instance); var cancellationToken = TestContext.Current.CancellationToken; 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.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); var readyEvent = Assert.Single(publisher.Events, evt => evt.Kind == OrchestratorEventKinds.ScannerReportReady); var links = Assert.IsType(readyEvent.Payload).Links; Assert.Equal("https://scanner.example/console/insights/report-abc", links.Report?.Ui); Assert.Equal("https://scanner.example/custom-api/reports-view/report-abc", links.Report?.Api); Assert.Equal("https://scanner.example/console/policy-center/revisions/rev-42", links.Policy?.Ui); Assert.Equal("https://scanner.example/custom-api/policy-hub/revisions/rev-42", links.Policy?.Api); Assert.Equal("https://scanner.example/console/evidence/report-abc", links.Attestation?.Ui); Assert.Equal("https://scanner.example/custom-api/reports-view/report-abc/attestation", links.Attestation?.Api); } private sealed class RecordingEventPublisher : IPlatformEventPublisher { public List Events { get; } = new(); public Task PublishAsync(OrchestratorEvent @event, CancellationToken cancellationToken = default) { Events.Add(@event); return Task.CompletedTask; } } private sealed class RecordingClassificationChangeTracker : IClassificationChangeTracker { public List Changes { get; } = new(); public bool ThrowOnTrack { get; init; } public Task TrackChangeAsync(ClassificationChange change, CancellationToken cancellationToken = default) { if (ThrowOnTrack) { throw new InvalidOperationException("Tracking failure"); } Changes.Add(change); return Task.CompletedTask; } public Task TrackChangesAsync(IEnumerable changes, CancellationToken cancellationToken = default) { if (ThrowOnTrack) { throw new InvalidOperationException("Tracking failure"); } Changes.AddRange(changes); return Task.CompletedTask; } public Task> ComputeDeltaAsync( Guid tenantId, string artifactDigest, Guid previousExecutionId, Guid currentExecutionId, CancellationToken cancellationToken = default) => Task.FromResult>(Array.Empty()); } }