using System.Net; using System.Net.Http; using System.Net.Http.Json; using System.Text.Json; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Scheduler.Models; using StellaOps.Scheduler.WebService.GraphJobs; using StellaOps.Scheduler.WebService.Options; namespace StellaOps.Scheduler.WebService.Tests; public sealed class CartographerWebhookClientTests { [Fact] public async Task NotifyAsync_PostsPayload_WhenEnabled() { var handler = new RecordingHandler(); var httpClient = new HttpClient(handler); var options = Microsoft.Extensions.Options.Options.Create(new SchedulerCartographerOptions { Webhook = { Enabled = true, Endpoint = "https://cartographer.local/hooks/graph-completed", ApiKeyHeader = "X-Api-Key", ApiKey = "secret" } }); using var loggerFactory = LoggerFactory.Create(builder => builder.AddDebug()); var client = new CartographerWebhookClient(httpClient, new OptionsMonitorStub(options), loggerFactory.CreateLogger()); var job = new GraphBuildJob( id: "gbj_test", tenantId: "tenant-alpha", sbomId: "sbom", sbomVersionId: "sbom_v1", sbomDigest: "sha256:" + new string('a', 64), status: GraphJobStatus.Completed, trigger: GraphBuildJobTrigger.Backfill, createdAt: DateTimeOffset.UtcNow, graphSnapshotId: "snap", attempts: 1, cartographerJobId: "carto-123", correlationId: "corr-1", startedAt: null, completedAt: DateTimeOffset.UtcNow, error: null, metadata: Array.Empty>()); var notification = new GraphJobCompletionNotification( job.TenantId, GraphJobQueryType.Build, GraphJobStatus.Completed, DateTimeOffset.UtcNow, GraphJobResponse.From(job), "oras://snap/result", "corr-1", null); await client.NotifyAsync(notification, CancellationToken.None); Assert.NotNull(handler.LastRequest); Assert.Equal("https://cartographer.local/hooks/graph-completed", handler.LastRequest.RequestUri!.ToString()); Assert.True(handler.LastRequest.Headers.TryGetValues("X-Api-Key", out var values) && values!.Single() == "secret"); var json = JsonSerializer.Deserialize(handler.LastPayload!); Assert.Equal("gbj_test", json.GetProperty("jobId").GetString()); Assert.Equal("tenant-alpha", json.GetProperty("tenantId").GetString()); } [Fact] public async Task NotifyAsync_Skips_WhenDisabled() { var handler = new RecordingHandler(); var httpClient = new HttpClient(handler); var options = Microsoft.Extensions.Options.Options.Create(new SchedulerCartographerOptions()); using var loggerFactory = LoggerFactory.Create(builder => builder.AddDebug()); var client = new CartographerWebhookClient(httpClient, new OptionsMonitorStub(options), loggerFactory.CreateLogger()); var job = new GraphOverlayJob( id: "goj-test", tenantId: "tenant-alpha", graphSnapshotId: "snap", overlayKind: GraphOverlayKind.Policy, overlayKey: "policy@1", status: GraphJobStatus.Completed, trigger: GraphOverlayJobTrigger.Manual, createdAt: DateTimeOffset.UtcNow, subjects: Array.Empty(), attempts: 1, correlationId: null, startedAt: null, completedAt: DateTimeOffset.UtcNow, error: null, metadata: Array.Empty>()); var notification = new GraphJobCompletionNotification( job.TenantId, GraphJobQueryType.Overlay, GraphJobStatus.Completed, DateTimeOffset.UtcNow, GraphJobResponse.From(job), null, null, null); await client.NotifyAsync(notification, CancellationToken.None); Assert.Null(handler.LastRequest); } private sealed class RecordingHandler : HttpMessageHandler { public HttpRequestMessage? LastRequest { get; private set; } public string? LastPayload { get; private set; } protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { LastRequest = request; LastPayload = request.Content is null ? null : request.Content.ReadAsStringAsync(cancellationToken).Result; return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); } } private sealed class OptionsMonitorStub : IOptionsMonitor where T : class { private readonly IOptions _options; public OptionsMonitorStub(IOptions options) { _options = options; } public T CurrentValue => _options.Value; public T Get(string? name) => _options.Value; public IDisposable? OnChange(Action listener) => null; } }