Files
git.stella-ops.org/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/ReportEventDispatcherTests.cs

507 lines
20 KiB
C#

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<ReportEventDispatcher>.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<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.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<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?.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<ReportEventDispatcher>.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<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
}
};
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<ReportEventDispatcher>.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<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(),
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<ReportEventDispatcher>.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<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);
var readyEvent = Assert.Single(publisher.Events, evt => evt.Kind == OrchestratorEventKinds.ScannerReportReady);
var links = Assert.IsType<ReportReadyEventPayload>(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<OrchestratorEvent> Events { get; } = new();
public Task PublishAsync(OrchestratorEvent @event, CancellationToken cancellationToken = default)
{
Events.Add(@event);
return Task.CompletedTask;
}
}
private sealed class RecordingClassificationChangeTracker : IClassificationChangeTracker
{
public List<ClassificationChange> 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<ClassificationChange> changes, CancellationToken cancellationToken = default)
{
if (ThrowOnTrack)
{
throw new InvalidOperationException("Tracking failure");
}
Changes.AddRange(changes);
return Task.CompletedTask;
}
public Task<IReadOnlyList<ClassificationChange>> ComputeDeltaAsync(
Guid tenantId,
string artifactDigest,
Guid previousExecutionId,
Guid currentExecutionId,
CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<ClassificationChange>>(Array.Empty<ClassificationChange>());
}
}