using System; using System.Collections.Generic; using System.Net; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; namespace StellaOps.Scheduler.WebService.Tests; public sealed class EventWebhookEndpointTests : IClassFixture> { static EventWebhookEndpointTests() { Environment.SetEnvironmentVariable("Scheduler__Events__Webhooks__Conselier__HmacSecret", ConselierSecret); Environment.SetEnvironmentVariable("Scheduler__Events__Webhooks__Conselier__Enabled", "true"); Environment.SetEnvironmentVariable("Scheduler__Events__Webhooks__Excitor__HmacSecret", ExcitorSecret); Environment.SetEnvironmentVariable("Scheduler__Events__Webhooks__Excitor__Enabled", "true"); } private const string ConselierSecret = "conselier-secret"; private const string ExcitorSecret = "excitor-secret"; private readonly WebApplicationFactory _factory; public EventWebhookEndpointTests(WebApplicationFactory factory) { _factory = factory; } [Trait("Category", TestCategories.Unit)] [Fact] public async Task ConselierWebhook_AcceptsValidSignature() { using var client = _factory.CreateClient(); var payload = new { exportId = "conselier-exp-1", changedProductKeys = new[] { "pkg:rpm/openssl", "pkg:deb/nginx" }, kev = new[] { "CVE-2024-0001" }, window = new { from = DateTimeOffset.UtcNow.AddHours(-1), to = DateTimeOffset.UtcNow } }; var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web)); using var request = new HttpRequestMessage(HttpMethod.Post, "/events/conselier-export") { Content = new StringContent(json, Encoding.UTF8, "application/json") }; request.Headers.TryAddWithoutValidation("X-Scheduler-Signature", ComputeSignature(ConselierSecret, json)); var response = await client.SendAsync(request); Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task ConselierWebhook_RejectsInvalidSignature() { using var client = _factory.CreateClient(); var payload = new { exportId = "conselier-exp-2", changedProductKeys = new[] { "pkg:nuget/log4net" } }; var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web)); using var request = new HttpRequestMessage(HttpMethod.Post, "/events/conselier-export") { Content = new StringContent(json, Encoding.UTF8, "application/json") }; request.Headers.TryAddWithoutValidation("X-Scheduler-Signature", "sha256=invalid"); var response = await client.SendAsync(request); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task ExcitorWebhook_HonoursRateLimit() { using var restrictedFactory = _factory.WithWebHostBuilder(builder => { builder.ConfigureAppConfiguration((_, configuration) => { configuration.AddInMemoryCollection(new Dictionary { ["Scheduler:Events:Webhooks:Excitor:RateLimitRequests"] = "1", ["Scheduler:Events:Webhooks:Excitor:RateLimitWindowSeconds"] = "60" }); }); }); using var client = restrictedFactory.CreateClient(); var payload = new { exportId = "excitor-exp-1", changedClaims = new[] { new { productKey = "pkg:deb/openssl", vulnerabilityId = "CVE-2024-1234", status = "affected" } } }; var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web)); using var first = new HttpRequestMessage(HttpMethod.Post, "/events/excitor-export") { Content = new StringContent(json, Encoding.UTF8, "application/json") }; first.Headers.TryAddWithoutValidation("X-Scheduler-Signature", ComputeSignature(ExcitorSecret, json)); var firstResponse = await client.SendAsync(first); Assert.Equal(HttpStatusCode.Accepted, firstResponse.StatusCode); using var second = new HttpRequestMessage(HttpMethod.Post, "/events/excitor-export") { Content = new StringContent(json, Encoding.UTF8, "application/json") }; second.Headers.TryAddWithoutValidation("X-Scheduler-Signature", ComputeSignature(ExcitorSecret, json)); var secondResponse = await client.SendAsync(second); Assert.Equal((HttpStatusCode)429, secondResponse.StatusCode); Assert.True(secondResponse.Headers.Contains("Retry-After")); } private static string ComputeSignature(string secret, string payload) { using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); using StellaOps.TestKit; var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload)); return "sha256=" + Convert.ToHexString(hash).ToLowerInvariant(); } }