using System.Globalization; using System.Net.Http.Headers; using System.Linq; using System.Text.Json; using StackExchange.Redis; static string RequireEnv(string name) { var value = Environment.GetEnvironmentVariable(name); if (string.IsNullOrWhiteSpace(value)) { throw new InvalidOperationException($"Environment variable '{name}' is required for Notify smoke validation."); } return value; } static string? GetField(StreamEntry entry, string fieldName) { foreach (var pair in entry.Values) { if (string.Equals(pair.Name, fieldName, StringComparison.OrdinalIgnoreCase)) { return pair.Value.ToString(); } } return null; } static void Ensure(bool condition, string message) { if (!condition) { throw new InvalidOperationException(message); } } var redisDsn = RequireEnv("NOTIFY_SMOKE_REDIS_DSN"); var redisStream = Environment.GetEnvironmentVariable("NOTIFY_SMOKE_STREAM"); if (string.IsNullOrWhiteSpace(redisStream)) { redisStream = "stella.events"; } var expectedKindsEnv = RequireEnv("NOTIFY_SMOKE_EXPECT_KINDS"); var expectedKinds = expectedKindsEnv .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(kind => kind.ToLowerInvariant()) .Distinct() .ToArray(); Ensure(expectedKinds.Length > 0, "Expected at least one event kind in NOTIFY_SMOKE_EXPECT_KINDS."); var lookbackMinutesEnv = RequireEnv("NOTIFY_SMOKE_LOOKBACK_MINUTES"); if (!double.TryParse(lookbackMinutesEnv, NumberStyles.Any, CultureInfo.InvariantCulture, out var lookbackMinutes)) { throw new InvalidOperationException("NOTIFY_SMOKE_LOOKBACK_MINUTES must be numeric."); } Ensure(lookbackMinutes > 0, "NOTIFY_SMOKE_LOOKBACK_MINUTES must be greater than zero."); var now = DateTimeOffset.UtcNow; var sinceThreshold = now - TimeSpan.FromMinutes(Math.Max(1, lookbackMinutes)); Console.WriteLine($"ℹ️ Checking Redis stream '{redisStream}' for kinds [{string.Join(", ", expectedKinds)}] within the last {lookbackMinutes:F1} minutes."); var redisConfig = ConfigurationOptions.Parse(redisDsn); redisConfig.AbortOnConnectFail = false; await using var redisConnection = await ConnectionMultiplexer.ConnectAsync(redisConfig); var database = redisConnection.GetDatabase(); var streamEntries = await database.StreamRangeAsync(redisStream, "-", "+", count: 200); if (streamEntries.Length > 1) { Array.Reverse(streamEntries); } Ensure(streamEntries.Length > 0, $"Redis stream '{redisStream}' is empty."); var recentEntries = new List(); foreach (var entry in streamEntries) { var timestampText = GetField(entry, "ts"); if (timestampText is null) { continue; } if (!DateTimeOffset.TryParse(timestampText, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var entryTimestamp)) { continue; } if (entryTimestamp >= sinceThreshold) { recentEntries.Add(entry); } } Ensure(recentEntries.Count > 0, $"No Redis events newer than {sinceThreshold:u} located in stream '{redisStream}'."); var missingKinds = new List(); foreach (var kind in expectedKinds) { var match = recentEntries.FirstOrDefault(entry => { var entryKind = GetField(entry, "kind")?.ToLowerInvariant(); return entryKind == kind; }); if (match.Equals(default(StreamEntry))) { missingKinds.Add(kind); } } Ensure(missingKinds.Count == 0, $"Missing expected Redis events for kinds: {string.Join(", ", missingKinds)}"); Console.WriteLine("✅ Redis event stream contains the expected scanner events."); var notifyBaseUrl = RequireEnv("NOTIFY_SMOKE_NOTIFY_BASEURL").TrimEnd('/'); var notifyToken = RequireEnv("NOTIFY_SMOKE_NOTIFY_TOKEN"); var notifyTenant = RequireEnv("NOTIFY_SMOKE_NOTIFY_TENANT"); var notifyTenantHeader = Environment.GetEnvironmentVariable("NOTIFY_SMOKE_NOTIFY_TENANT_HEADER"); if (string.IsNullOrWhiteSpace(notifyTenantHeader)) { notifyTenantHeader = "X-StellaOps-Tenant"; } var notifyTimeoutSeconds = 30; var notifyTimeoutEnv = Environment.GetEnvironmentVariable("NOTIFY_SMOKE_NOTIFY_TIMEOUT_SECONDS"); if (!string.IsNullOrWhiteSpace(notifyTimeoutEnv) && int.TryParse(notifyTimeoutEnv, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedTimeout)) { notifyTimeoutSeconds = Math.Max(5, parsedTimeout); } using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(notifyTimeoutSeconds), }; httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", notifyToken); httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); httpClient.DefaultRequestHeaders.Add(notifyTenantHeader, notifyTenant); var sinceQuery = Uri.EscapeDataString(sinceThreshold.ToString("O", CultureInfo.InvariantCulture)); var deliveriesUrl = $"{notifyBaseUrl}/api/v1/deliveries?since={sinceQuery}&limit=200"; Console.WriteLine($"ℹ️ Querying Notify deliveries via {deliveriesUrl}."); using var response = await httpClient.GetAsync(deliveriesUrl); if (!response.IsSuccessStatusCode) { var body = await response.Content.ReadAsStringAsync(); throw new InvalidOperationException($"Notify deliveries request failed with {(int)response.StatusCode} {response.ReasonPhrase}: {body}"); } var json = await response.Content.ReadAsStringAsync(); if (string.IsNullOrWhiteSpace(json)) { throw new InvalidOperationException("Notify deliveries response body was empty."); } using var document = JsonDocument.Parse(json); var root = document.RootElement; IEnumerable EnumerateDeliveries(JsonElement element) { return element.ValueKind switch { JsonValueKind.Array => element.EnumerateArray(), JsonValueKind.Object when element.TryGetProperty("items", out var items) && items.ValueKind == JsonValueKind.Array => items.EnumerateArray(), _ => throw new InvalidOperationException("Notify deliveries response was not an array or did not contain an 'items' collection.") }; } var deliveries = EnumerateDeliveries(root).ToArray(); Ensure(deliveries.Length > 0, "Notify deliveries response did not return any records."); var missingDeliveryKinds = new List(); foreach (var kind in expectedKinds) { var found = deliveries.Any(delivery => delivery.TryGetProperty("kind", out var kindProperty) && kindProperty.GetString()?.Equals(kind, StringComparison.OrdinalIgnoreCase) == true && delivery.TryGetProperty("status", out var statusProperty) && !string.Equals(statusProperty.GetString(), "failed", StringComparison.OrdinalIgnoreCase)); if (!found) { missingDeliveryKinds.Add(kind); } } Ensure(missingDeliveryKinds.Count == 0, $"Notify deliveries missing successful records for kinds: {string.Join(", ", missingDeliveryKinds)}"); Console.WriteLine("✅ Notify deliveries include the expected scanner events."); Console.WriteLine("🎉 Notify smoke validation completed successfully.");