notify doctors work, audit work, new product advisory sprints
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugin.Notify.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if email (SMTP) notification channel is properly configured.
|
||||
/// </summary>
|
||||
public sealed class EmailConfiguredCheck : IDoctorCheck
|
||||
{
|
||||
private const string PluginId = "stellaops.doctor.notify";
|
||||
private const string CategoryName = "Notifications";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.notify.email.configured";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Email Configuration";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verify email (SMTP) notification channel is properly configured";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["notify", "email", "smtp", "quick", "configuration"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(100);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
var emailConfig = context.Configuration.GetSection("Notify:Channels:Email");
|
||||
return emailConfig.Exists();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var builder = context.CreateResult(CheckId, PluginId, CategoryName);
|
||||
var emailConfig = context.Configuration.GetSection("Notify:Channels:Email");
|
||||
|
||||
var smtpHost = emailConfig["SmtpHost"] ?? emailConfig["Host"];
|
||||
var smtpPort = emailConfig.GetValue<int?>("SmtpPort") ?? emailConfig.GetValue<int?>("Port") ?? 0;
|
||||
var fromAddress = emailConfig["FromAddress"] ?? emailConfig["From"];
|
||||
var enabled = emailConfig.GetValue<bool>("Enabled", true);
|
||||
var useSsl = emailConfig.GetValue<bool>("UseSsl", true);
|
||||
var username = emailConfig["Username"];
|
||||
|
||||
var hasHost = !string.IsNullOrWhiteSpace(smtpHost);
|
||||
var hasFrom = !string.IsNullOrWhiteSpace(fromAddress);
|
||||
var hasValidPort = smtpPort > 0 && smtpPort <= 65535;
|
||||
|
||||
if (!hasHost)
|
||||
{
|
||||
return Task.FromResult(builder
|
||||
.Fail("SMTP host is not configured")
|
||||
.WithEvidence("Email configuration status", eb => eb
|
||||
.Add("SmtpHost", "(not set)")
|
||||
.Add("SmtpPort", smtpPort > 0 ? smtpPort.ToString() : "(not set)")
|
||||
.Add("FromAddress", hasFrom ? fromAddress! : "(not set)")
|
||||
.Add("Enabled", enabled.ToString()))
|
||||
.WithCauses(
|
||||
"SMTP host not set in configuration",
|
||||
"Missing Notify:Channels:Email:SmtpHost setting")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Add SMTP configuration",
|
||||
"# Add to appsettings.json:\n" +
|
||||
"# \"Notify\": { \"Channels\": { \"Email\": {\n" +
|
||||
"# \"SmtpHost\": \"smtp.example.com\",\n" +
|
||||
"# \"SmtpPort\": 587,\n" +
|
||||
"# \"FromAddress\": \"noreply@example.com\",\n" +
|
||||
"# \"UseSsl\": true\n" +
|
||||
"# } } }",
|
||||
CommandType.FileEdit)
|
||||
.AddStep(2, "Or set via environment variables",
|
||||
"export Notify__Channels__Email__SmtpHost=\"smtp.example.com\"\n" +
|
||||
"export Notify__Channels__Email__SmtpPort=\"587\"\n" +
|
||||
"export Notify__Channels__Email__FromAddress=\"noreply@example.com\"",
|
||||
CommandType.Shell))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build());
|
||||
}
|
||||
|
||||
if (!hasValidPort)
|
||||
{
|
||||
return Task.FromResult(builder
|
||||
.Warn("SMTP port is not configured or invalid")
|
||||
.WithEvidence("Email configuration status", eb => eb
|
||||
.Add("SmtpHost", smtpHost!)
|
||||
.Add("SmtpPort", smtpPort > 0 ? smtpPort.ToString() : "(not set or invalid)")
|
||||
.Add("FromAddress", hasFrom ? fromAddress! : "(not set)")
|
||||
.Add("Enabled", enabled.ToString())
|
||||
.Add("Note", "Common ports: 25 (unencrypted), 465 (SSL), 587 (TLS/STARTTLS)"))
|
||||
.WithCauses(
|
||||
"SMTP port not specified",
|
||||
"Invalid port number")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Set SMTP port",
|
||||
"# Common SMTP ports:\n# 25 - Standard SMTP (often blocked)\n# 465 - SMTP over SSL\n# 587 - SMTP with STARTTLS (recommended)",
|
||||
CommandType.Manual))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build());
|
||||
}
|
||||
|
||||
if (!hasFrom)
|
||||
{
|
||||
return Task.FromResult(builder
|
||||
.Warn("From address is not configured")
|
||||
.WithEvidence("Email configuration status", eb => eb
|
||||
.Add("SmtpHost", smtpHost!)
|
||||
.Add("SmtpPort", smtpPort.ToString())
|
||||
.Add("FromAddress", "(not set)")
|
||||
.Add("Enabled", enabled.ToString()))
|
||||
.WithCauses(
|
||||
"From address not configured",
|
||||
"Emails may be rejected without a valid sender")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Set from address",
|
||||
"# Add Notify:Channels:Email:FromAddress to configuration",
|
||||
CommandType.FileEdit))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build());
|
||||
}
|
||||
|
||||
if (!enabled)
|
||||
{
|
||||
return Task.FromResult(builder
|
||||
.Warn("Email channel is configured but disabled")
|
||||
.WithEvidence("Email configuration status", eb => eb
|
||||
.Add("SmtpHost", smtpHost!)
|
||||
.Add("SmtpPort", smtpPort.ToString())
|
||||
.Add("FromAddress", fromAddress!)
|
||||
.Add("Enabled", "false")
|
||||
.Add("UseSsl", useSsl.ToString())
|
||||
.Add("HasCredentials", !string.IsNullOrWhiteSpace(username) ? "yes" : "no"))
|
||||
.WithCauses(
|
||||
"Email notifications explicitly disabled")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Enable email notifications",
|
||||
"# Set Notify:Channels:Email:Enabled to true",
|
||||
CommandType.FileEdit))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(builder
|
||||
.Pass("Email notification channel is properly configured")
|
||||
.WithEvidence("Email configuration status", eb => eb
|
||||
.Add("SmtpHost", smtpHost!)
|
||||
.Add("SmtpPort", smtpPort.ToString())
|
||||
.Add("FromAddress", fromAddress!)
|
||||
.Add("Enabled", "true")
|
||||
.Add("UseSsl", useSsl.ToString())
|
||||
.Add("HasCredentials", !string.IsNullOrWhiteSpace(username) ? "yes" : "no"))
|
||||
.Build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
using System.Globalization;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugin.Notify.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the configured SMTP server is reachable.
|
||||
/// </summary>
|
||||
public sealed class EmailConnectivityCheck : IDoctorCheck
|
||||
{
|
||||
private const string PluginId = "stellaops.doctor.notify";
|
||||
private const string CategoryName = "Notifications";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.notify.email.connectivity";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Email Connectivity";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verify SMTP server is reachable";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["notify", "email", "smtp", "connectivity", "network"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
var emailConfig = context.Configuration.GetSection("Notify:Channels:Email");
|
||||
var smtpHost = emailConfig["SmtpHost"] ?? emailConfig["Host"];
|
||||
var smtpPort = emailConfig.GetValue<int?>("SmtpPort") ?? emailConfig.GetValue<int?>("Port") ?? 0;
|
||||
|
||||
return !string.IsNullOrWhiteSpace(smtpHost) && smtpPort > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var emailConfig = context.Configuration.GetSection("Notify:Channels:Email");
|
||||
var smtpHost = emailConfig["SmtpHost"] ?? emailConfig["Host"]!;
|
||||
var smtpPort = emailConfig.GetValue<int?>("SmtpPort") ?? emailConfig.GetValue<int?>("Port") ?? 587;
|
||||
|
||||
var builder = context.CreateResult(CheckId, PluginId, CategoryName);
|
||||
|
||||
try
|
||||
{
|
||||
using var tcpClient = new TcpClient();
|
||||
|
||||
// Set connection timeout
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(10));
|
||||
|
||||
await tcpClient.ConnectAsync(smtpHost, smtpPort, timeoutCts.Token);
|
||||
|
||||
if (tcpClient.Connected)
|
||||
{
|
||||
// Try to read the SMTP banner
|
||||
using var stream = tcpClient.GetStream();
|
||||
stream.ReadTimeout = 5000;
|
||||
|
||||
var buffer = new byte[1024];
|
||||
string? banner = null;
|
||||
|
||||
try
|
||||
{
|
||||
var bytesRead = await stream.ReadAsync(buffer, timeoutCts.Token);
|
||||
if (bytesRead > 0)
|
||||
{
|
||||
banner = System.Text.Encoding.ASCII.GetString(buffer, 0, bytesRead).Trim();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Banner read failed, but connection succeeded
|
||||
}
|
||||
|
||||
var isSmtp = banner?.StartsWith("220", StringComparison.Ordinal) == true;
|
||||
|
||||
if (isSmtp)
|
||||
{
|
||||
return builder
|
||||
.Pass("SMTP server is reachable and responding")
|
||||
.WithEvidence("SMTP connectivity test", eb => eb
|
||||
.Add("SmtpHost", smtpHost)
|
||||
.Add("SmtpPort", smtpPort.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("Banner", banner?.Length > 100 ? banner[..100] + "..." : banner ?? "(none)"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
return builder
|
||||
.Info("Connection to SMTP port succeeded but banner not recognized")
|
||||
.WithEvidence("SMTP connectivity test", eb => eb
|
||||
.Add("SmtpHost", smtpHost)
|
||||
.Add("SmtpPort", smtpPort.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("Banner", banner ?? "(none)")
|
||||
.Add("Note", "Connection succeeded but response doesn't look like SMTP"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
return builder
|
||||
.Fail("Failed to connect to SMTP server")
|
||||
.WithEvidence("SMTP connectivity test", eb => eb
|
||||
.Add("SmtpHost", smtpHost)
|
||||
.Add("SmtpPort", smtpPort.ToString(CultureInfo.InvariantCulture)))
|
||||
.WithCauses(
|
||||
"SMTP server not running",
|
||||
"Wrong host or port",
|
||||
"Firewall blocking connection")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Test port connectivity",
|
||||
$"nc -zv {smtpHost} {smtpPort}",
|
||||
CommandType.Shell)
|
||||
.AddStep(2, "Test with telnet",
|
||||
$"telnet {smtpHost} {smtpPort}",
|
||||
CommandType.Shell))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return builder
|
||||
.Fail("SMTP connection timed out")
|
||||
.WithEvidence("SMTP connectivity test", eb => eb
|
||||
.Add("SmtpHost", smtpHost)
|
||||
.Add("SmtpPort", smtpPort.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("Error", "Connection timeout (10s)"))
|
||||
.WithCauses(
|
||||
"SMTP server not responding",
|
||||
"Network latency too high",
|
||||
"Firewall blocking connection",
|
||||
"Wrong host or port")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Test DNS resolution",
|
||||
$"nslookup {smtpHost}",
|
||||
CommandType.Shell)
|
||||
.AddStep(2, "Test port connectivity",
|
||||
$"nc -zv -w 10 {smtpHost} {smtpPort}",
|
||||
CommandType.Shell)
|
||||
.AddStep(3, "Check firewall rules",
|
||||
"# Ensure outbound connections to SMTP ports are allowed",
|
||||
CommandType.Manual))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
return builder
|
||||
.Fail($"Cannot connect to SMTP server: {ex.Message}")
|
||||
.WithEvidence("SMTP connectivity test", eb => eb
|
||||
.Add("SmtpHost", smtpHost)
|
||||
.Add("SmtpPort", smtpPort.ToString(CultureInfo.InvariantCulture))
|
||||
.Add("SocketError", ex.SocketErrorCode.ToString())
|
||||
.Add("Error", ex.Message))
|
||||
.WithCauses(
|
||||
"DNS resolution failure",
|
||||
"SMTP server not running on specified port",
|
||||
"Network connectivity issue",
|
||||
"Firewall blocking connection")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Test DNS resolution",
|
||||
$"nslookup {smtpHost}",
|
||||
CommandType.Shell)
|
||||
.AddStep(2, "Test port connectivity",
|
||||
$"nc -zv {smtpHost} {smtpPort}",
|
||||
CommandType.Shell)
|
||||
.AddStep(3, "Verify SMTP host and port settings",
|
||||
"# Common SMTP ports: 25, 465 (SSL), 587 (STARTTLS)",
|
||||
CommandType.Manual))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugin.Notify.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the notification queue (Redis or NATS) is healthy.
|
||||
/// </summary>
|
||||
public sealed class NotifyQueueHealthCheck : IDoctorCheck
|
||||
{
|
||||
private const string PluginId = "stellaops.doctor.notify";
|
||||
private const string CategoryName = "Notifications";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.notify.queue.health";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Notification Queue Health";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verify notification event and delivery queues are healthy";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Fail;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["notify", "queue", "redis", "nats", "infrastructure"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
// Check if any queue configuration exists
|
||||
var queueConfig = context.Configuration.GetSection("Notify:Queue");
|
||||
var transportKind = queueConfig["Transport"] ?? queueConfig["Kind"];
|
||||
return !string.IsNullOrWhiteSpace(transportKind);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var builder = context.CreateResult(CheckId, PluginId, CategoryName);
|
||||
var queueConfig = context.Configuration.GetSection("Notify:Queue");
|
||||
var transportKind = queueConfig["Transport"] ?? queueConfig["Kind"] ?? "unknown";
|
||||
|
||||
// Try to get the event queue health check from DI
|
||||
var eventQueueHealthCheck = context.Services.GetService<StellaOps.Notify.Queue.NotifyQueueHealthCheck>();
|
||||
var deliveryQueueHealthCheck = context.Services.GetService<StellaOps.Notify.Queue.NotifyDeliveryQueueHealthCheck>();
|
||||
|
||||
if (eventQueueHealthCheck == null && deliveryQueueHealthCheck == null)
|
||||
{
|
||||
return builder
|
||||
.Skip("No notification queue health checks registered")
|
||||
.WithEvidence("Queue health check status", eb => eb
|
||||
.Add("Transport", transportKind)
|
||||
.Add("EventQueueHealthCheck", "not registered")
|
||||
.Add("DeliveryQueueHealthCheck", "not registered"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
var results = new List<(string Name, HealthCheckResult Result)>();
|
||||
|
||||
// Check event queue
|
||||
if (eventQueueHealthCheck != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var eventContext = new HealthCheckContext
|
||||
{
|
||||
Registration = new HealthCheckRegistration(
|
||||
"notify-event-queue",
|
||||
eventQueueHealthCheck,
|
||||
HealthStatus.Unhealthy,
|
||||
null)
|
||||
};
|
||||
|
||||
var eventResult = await eventQueueHealthCheck.CheckHealthAsync(eventContext, ct);
|
||||
results.Add(("EventQueue", eventResult));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results.Add(("EventQueue", new HealthCheckResult(
|
||||
HealthStatus.Unhealthy,
|
||||
"Event queue health check threw exception",
|
||||
ex)));
|
||||
}
|
||||
}
|
||||
|
||||
// Check delivery queue
|
||||
if (deliveryQueueHealthCheck != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var deliveryContext = new HealthCheckContext
|
||||
{
|
||||
Registration = new HealthCheckRegistration(
|
||||
"notify-delivery-queue",
|
||||
deliveryQueueHealthCheck,
|
||||
HealthStatus.Unhealthy,
|
||||
null)
|
||||
};
|
||||
|
||||
var deliveryResult = await deliveryQueueHealthCheck.CheckHealthAsync(deliveryContext, ct);
|
||||
results.Add(("DeliveryQueue", deliveryResult));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results.Add(("DeliveryQueue", new HealthCheckResult(
|
||||
HealthStatus.Unhealthy,
|
||||
"Delivery queue health check threw exception",
|
||||
ex)));
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate results
|
||||
var allHealthy = results.All(r => r.Result.Status == HealthStatus.Healthy);
|
||||
var anyUnhealthy = results.Any(r => r.Result.Status == HealthStatus.Unhealthy);
|
||||
|
||||
if (allHealthy)
|
||||
{
|
||||
return builder
|
||||
.Pass($"Notification queue ({transportKind}) is healthy")
|
||||
.WithEvidence("Queue health check results", eb =>
|
||||
{
|
||||
eb.Add("Transport", transportKind);
|
||||
foreach (var (name, result) in results)
|
||||
{
|
||||
eb.Add($"{name}Status", result.Status.ToString());
|
||||
if (!string.IsNullOrEmpty(result.Description))
|
||||
{
|
||||
eb.Add($"{name}Message", result.Description);
|
||||
}
|
||||
}
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
if (anyUnhealthy)
|
||||
{
|
||||
var unhealthyQueues = results
|
||||
.Where(r => r.Result.Status == HealthStatus.Unhealthy)
|
||||
.Select(r => r.Name)
|
||||
.ToList();
|
||||
|
||||
return builder
|
||||
.Fail($"Notification queue unhealthy: {string.Join(", ", unhealthyQueues)}")
|
||||
.WithEvidence("Queue health check results", eb =>
|
||||
{
|
||||
eb.Add("Transport", transportKind);
|
||||
foreach (var (name, result) in results)
|
||||
{
|
||||
eb.Add($"{name}Status", result.Status.ToString());
|
||||
if (!string.IsNullOrEmpty(result.Description))
|
||||
{
|
||||
eb.Add($"{name}Message", result.Description);
|
||||
}
|
||||
}
|
||||
})
|
||||
.WithCauses(
|
||||
"Queue server not running",
|
||||
"Network connectivity issues",
|
||||
"Authentication failure",
|
||||
"Incorrect connection string")
|
||||
.WithRemediation(rb =>
|
||||
{
|
||||
if (transportKind.Equals("redis", StringComparison.OrdinalIgnoreCase) ||
|
||||
transportKind.Equals("valkey", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
rb.AddStep(1, "Check Redis/Valkey server status",
|
||||
"redis-cli ping",
|
||||
CommandType.Shell)
|
||||
.AddStep(2, "Verify Redis connection settings",
|
||||
"# Check Notify:Queue:Redis:ConnectionString in configuration",
|
||||
CommandType.Manual)
|
||||
.AddStep(3, "Check Redis server logs",
|
||||
"docker logs <redis-container-name>",
|
||||
CommandType.Shell);
|
||||
}
|
||||
else if (transportKind.Equals("nats", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
rb.AddStep(1, "Check NATS server status",
|
||||
"nats server ping",
|
||||
CommandType.Shell)
|
||||
.AddStep(2, "Verify NATS connection settings",
|
||||
"# Check Notify:Queue:Nats:Url in configuration",
|
||||
CommandType.Manual)
|
||||
.AddStep(3, "Check NATS server logs",
|
||||
"docker logs <nats-container-name>",
|
||||
CommandType.Shell);
|
||||
}
|
||||
else
|
||||
{
|
||||
rb.AddStep(1, "Verify queue transport configuration",
|
||||
"# Check Notify:Queue:Transport setting",
|
||||
CommandType.Manual);
|
||||
}
|
||||
})
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
|
||||
// Degraded state
|
||||
return builder
|
||||
.Warn("Notification queue in degraded state")
|
||||
.WithEvidence("Queue health check results", eb =>
|
||||
{
|
||||
eb.Add("Transport", transportKind);
|
||||
foreach (var (name, result) in results)
|
||||
{
|
||||
eb.Add($"{name}Status", result.Status.ToString());
|
||||
if (!string.IsNullOrEmpty(result.Description))
|
||||
{
|
||||
eb.Add($"{name}Message", result.Description);
|
||||
}
|
||||
}
|
||||
})
|
||||
.WithCauses(
|
||||
"Queue server experiencing issues",
|
||||
"High latency",
|
||||
"Resource constraints")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Check queue server health",
|
||||
"# Review queue server metrics and logs",
|
||||
CommandType.Manual))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Doctor.Plugin.Notify.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if Slack notification channels are properly configured.
|
||||
/// </summary>
|
||||
public sealed class SlackConfiguredCheck : IDoctorCheck
|
||||
{
|
||||
private const string PluginId = "stellaops.doctor.notify";
|
||||
private const string CategoryName = "Notifications";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.notify.slack.configured";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Slack Configuration";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verify Slack notification channel is properly configured";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["notify", "slack", "quick", "configuration"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(100);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
// Check if Slack is configured in settings
|
||||
var slackConfig = context.Configuration.GetSection("Notify:Channels:Slack");
|
||||
return slackConfig.Exists();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var builder = context.CreateResult(CheckId, PluginId, CategoryName);
|
||||
var slackConfig = context.Configuration.GetSection("Notify:Channels:Slack");
|
||||
|
||||
var webhookUrl = slackConfig["WebhookUrl"];
|
||||
var channel = slackConfig["Channel"];
|
||||
var enabled = slackConfig.GetValue<bool>("Enabled", true);
|
||||
|
||||
var hasWebhook = !string.IsNullOrWhiteSpace(webhookUrl);
|
||||
var hasChannel = !string.IsNullOrWhiteSpace(channel);
|
||||
|
||||
if (!hasWebhook)
|
||||
{
|
||||
return Task.FromResult(builder
|
||||
.Fail("Slack webhook URL is not configured")
|
||||
.WithEvidence("Slack configuration status", eb => eb
|
||||
.Add("WebhookUrl", "(not set)")
|
||||
.Add("Channel", hasChannel ? channel! : "(not set)")
|
||||
.Add("Enabled", enabled.ToString()))
|
||||
.WithCauses(
|
||||
"Slack webhook URL not set in configuration",
|
||||
"Missing Notify:Channels:Slack:WebhookUrl setting",
|
||||
"Environment variable not bound to configuration")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Add Slack webhook URL to configuration",
|
||||
"# Add to appsettings.json or environment:\n" +
|
||||
"# \"Notify\": { \"Channels\": { \"Slack\": { \"WebhookUrl\": \"https://hooks.slack.com/services/...\" } } }",
|
||||
CommandType.FileEdit)
|
||||
.AddStep(2, "Or set via environment variable",
|
||||
"export Notify__Channels__Slack__WebhookUrl=\"https://hooks.slack.com/services/YOUR/WEBHOOK/URL\"",
|
||||
CommandType.Shell)
|
||||
.WithSafetyNote("Slack webhook URLs are secrets - store in a secrets manager"))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build());
|
||||
}
|
||||
|
||||
if (!enabled)
|
||||
{
|
||||
return Task.FromResult(builder
|
||||
.Warn("Slack channel is configured but disabled")
|
||||
.WithEvidence("Slack configuration status", eb => eb
|
||||
.Add("WebhookUrl", DoctorPluginContext.Redact(webhookUrl))
|
||||
.Add("Channel", hasChannel ? channel! : "(default)")
|
||||
.Add("Enabled", "false"))
|
||||
.WithCauses(
|
||||
"Slack notifications explicitly disabled in configuration")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Enable Slack notifications",
|
||||
"# Set Notify:Channels:Slack:Enabled to true in configuration",
|
||||
CommandType.FileEdit))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(builder
|
||||
.Pass("Slack notification channel is properly configured")
|
||||
.WithEvidence("Slack configuration status", eb => eb
|
||||
.Add("WebhookUrl", DoctorPluginContext.Redact(webhookUrl))
|
||||
.Add("Channel", hasChannel ? channel! : "(default)")
|
||||
.Add("Enabled", "true"))
|
||||
.Build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.Globalization;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugin.Notify.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the configured Slack webhook endpoint is reachable.
|
||||
/// </summary>
|
||||
public sealed class SlackConnectivityCheck : IDoctorCheck
|
||||
{
|
||||
private const string PluginId = "stellaops.doctor.notify";
|
||||
private const string CategoryName = "Notifications";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.notify.slack.connectivity";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Slack Connectivity";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verify Slack webhook endpoint is reachable";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["notify", "slack", "connectivity", "network"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
var webhookUrl = context.Configuration["Notify:Channels:Slack:WebhookUrl"];
|
||||
return !string.IsNullOrWhiteSpace(webhookUrl) &&
|
||||
Uri.TryCreate(webhookUrl, UriKind.Absolute, out _);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var webhookUrl = context.Configuration["Notify:Channels:Slack:WebhookUrl"]!;
|
||||
var builder = context.CreateResult(CheckId, PluginId, CategoryName);
|
||||
|
||||
try
|
||||
{
|
||||
var httpClientFactory = context.Services.GetRequiredService<IHttpClientFactory>();
|
||||
var httpClient = httpClientFactory.CreateClient("DoctorHealthCheck");
|
||||
httpClient.Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
// Send a minimal test payload to Slack
|
||||
// Note: This won't actually post a message if the payload is invalid,
|
||||
// but it will verify the endpoint is reachable and responds
|
||||
var testPayload = new { text = "" }; // Empty text won't post but validates endpoint
|
||||
var content = new StringContent(
|
||||
JsonSerializer.Serialize(testPayload),
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
|
||||
var response = await httpClient.PostAsync(webhookUrl, content, ct);
|
||||
var responseBody = await response.Content.ReadAsStringAsync(ct);
|
||||
|
||||
// Slack returns "no_text" for empty messages, which proves connectivity
|
||||
if (response.IsSuccessStatusCode || responseBody.Contains("no_text", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return builder
|
||||
.Pass("Slack webhook endpoint is reachable")
|
||||
.WithEvidence("Slack connectivity test", eb => eb
|
||||
.Add("WebhookUrl", DoctorPluginContext.Redact(webhookUrl))
|
||||
.Add("StatusCode", ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture))
|
||||
.Add("Response", responseBody.Length > 100 ? responseBody[..100] + "..." : responseBody))
|
||||
.Build();
|
||||
}
|
||||
|
||||
return builder
|
||||
.Warn($"Slack webhook returned unexpected response: {response.StatusCode}")
|
||||
.WithEvidence("Slack connectivity test", eb => eb
|
||||
.Add("WebhookUrl", DoctorPluginContext.Redact(webhookUrl))
|
||||
.Add("StatusCode", ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture))
|
||||
.Add("Response", responseBody.Length > 200 ? responseBody[..200] + "..." : responseBody))
|
||||
.WithCauses(
|
||||
"Invalid or expired webhook URL",
|
||||
"Slack workspace configuration changed",
|
||||
"Webhook URL revoked or regenerated",
|
||||
"Rate limiting by Slack")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Verify webhook URL in Slack App settings",
|
||||
"# Go to https://api.slack.com/apps -> Your App -> Incoming Webhooks",
|
||||
CommandType.Manual)
|
||||
.AddStep(2, "Test webhook manually",
|
||||
$"curl -X POST -H 'Content-type: application/json' --data '{{\"text\":\"Doctor test\"}}' '{DoctorPluginContext.Redact(webhookUrl)}'",
|
||||
CommandType.Shell)
|
||||
.AddStep(3, "Regenerate webhook if needed",
|
||||
"# Create a new webhook URL in Slack and update configuration",
|
||||
CommandType.Manual))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return builder
|
||||
.Fail("Slack webhook connection timed out")
|
||||
.WithEvidence("Slack connectivity test", eb => eb
|
||||
.Add("WebhookUrl", DoctorPluginContext.Redact(webhookUrl))
|
||||
.Add("Error", "Connection timeout (10s)"))
|
||||
.WithCauses(
|
||||
"Network connectivity issue to Slack",
|
||||
"Firewall blocking outbound HTTPS",
|
||||
"Proxy configuration required",
|
||||
"Slack service degradation")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Check network connectivity",
|
||||
"curl -v https://hooks.slack.com/",
|
||||
CommandType.Shell)
|
||||
.AddStep(2, "Check Slack status",
|
||||
"# Visit https://status.slack.com for service status",
|
||||
CommandType.Manual)
|
||||
.AddStep(3, "Verify proxy settings if applicable",
|
||||
"echo $HTTP_PROXY $HTTPS_PROXY",
|
||||
CommandType.Shell))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return builder
|
||||
.Fail($"Cannot reach Slack webhook: {ex.Message}")
|
||||
.WithEvidence("Slack connectivity test", eb => eb
|
||||
.Add("WebhookUrl", DoctorPluginContext.Redact(webhookUrl))
|
||||
.Add("Error", ex.Message))
|
||||
.WithCauses(
|
||||
"DNS resolution failure",
|
||||
"Network connectivity issue",
|
||||
"TLS/SSL certificate problem",
|
||||
"Firewall blocking connection")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Test DNS resolution",
|
||||
"nslookup hooks.slack.com",
|
||||
CommandType.Shell)
|
||||
.AddStep(2, "Test HTTPS connectivity",
|
||||
"curl -v https://hooks.slack.com/",
|
||||
CommandType.Shell))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugin.Notify.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if Microsoft Teams notification channels are properly configured.
|
||||
/// </summary>
|
||||
public sealed class TeamsConfiguredCheck : IDoctorCheck
|
||||
{
|
||||
private const string PluginId = "stellaops.doctor.notify";
|
||||
private const string CategoryName = "Notifications";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.notify.teams.configured";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Teams Configuration";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verify Microsoft Teams notification channel is properly configured";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["notify", "teams", "quick", "configuration"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(100);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
var teamsConfig = context.Configuration.GetSection("Notify:Channels:Teams");
|
||||
return teamsConfig.Exists();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var builder = context.CreateResult(CheckId, PluginId, CategoryName);
|
||||
var teamsConfig = context.Configuration.GetSection("Notify:Channels:Teams");
|
||||
|
||||
var webhookUrl = teamsConfig["WebhookUrl"];
|
||||
var enabled = teamsConfig.GetValue<bool>("Enabled", true);
|
||||
|
||||
var hasWebhook = !string.IsNullOrWhiteSpace(webhookUrl);
|
||||
var isValidUrl = hasWebhook && Uri.TryCreate(webhookUrl, UriKind.Absolute, out var uri) &&
|
||||
(uri.Host.Contains("webhook.office.com", StringComparison.OrdinalIgnoreCase) ||
|
||||
uri.Host.Contains("microsoft.com", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (!hasWebhook)
|
||||
{
|
||||
return Task.FromResult(builder
|
||||
.Fail("Teams webhook URL is not configured")
|
||||
.WithEvidence("Teams configuration status", eb => eb
|
||||
.Add("WebhookUrl", "(not set)")
|
||||
.Add("Enabled", enabled.ToString()))
|
||||
.WithCauses(
|
||||
"Teams webhook URL not set in configuration",
|
||||
"Missing Notify:Channels:Teams:WebhookUrl setting",
|
||||
"Environment variable not bound to configuration")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Create Teams Incoming Webhook",
|
||||
"# In Teams: Channel > Connectors > Incoming Webhook > Create",
|
||||
CommandType.Manual)
|
||||
.AddStep(2, "Add webhook URL to configuration",
|
||||
"# Add to appsettings.json:\n" +
|
||||
"# \"Notify\": { \"Channels\": { \"Teams\": { \"WebhookUrl\": \"https://...webhook.office.com/...\" } } }",
|
||||
CommandType.FileEdit)
|
||||
.AddStep(3, "Or set via environment variable",
|
||||
"export Notify__Channels__Teams__WebhookUrl=\"https://YOUR_WEBHOOK_URL\"",
|
||||
CommandType.Shell)
|
||||
.WithSafetyNote("Teams webhook URLs are secrets - store securely"))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build());
|
||||
}
|
||||
|
||||
if (!isValidUrl)
|
||||
{
|
||||
return Task.FromResult(builder
|
||||
.Warn("Teams webhook URL format appears invalid")
|
||||
.WithEvidence("Teams configuration status", eb => eb
|
||||
.Add("WebhookUrl", DoctorPluginContext.Redact(webhookUrl))
|
||||
.Add("Enabled", enabled.ToString())
|
||||
.Add("ValidationNote", "Expected webhook.office.com or microsoft.com domain"))
|
||||
.WithCauses(
|
||||
"Webhook URL is not from Microsoft domain",
|
||||
"Malformed URL in configuration",
|
||||
"Legacy webhook URL format")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Verify webhook URL",
|
||||
"# Teams webhook URLs typically look like:\n# https://YOUR_TENANT.webhook.office.com/webhookb2/...",
|
||||
CommandType.Manual))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build());
|
||||
}
|
||||
|
||||
if (!enabled)
|
||||
{
|
||||
return Task.FromResult(builder
|
||||
.Warn("Teams channel is configured but disabled")
|
||||
.WithEvidence("Teams configuration status", eb => eb
|
||||
.Add("WebhookUrl", DoctorPluginContext.Redact(webhookUrl))
|
||||
.Add("Enabled", "false"))
|
||||
.WithCauses(
|
||||
"Teams notifications explicitly disabled in configuration")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Enable Teams notifications",
|
||||
"# Set Notify:Channels:Teams:Enabled to true in configuration",
|
||||
CommandType.FileEdit))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(builder
|
||||
.Pass("Teams notification channel is properly configured")
|
||||
.WithEvidence("Teams configuration status", eb => eb
|
||||
.Add("WebhookUrl", DoctorPluginContext.Redact(webhookUrl))
|
||||
.Add("Enabled", "true"))
|
||||
.Build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using System.Globalization;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugin.Notify.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the configured Microsoft Teams webhook endpoint is reachable.
|
||||
/// </summary>
|
||||
public sealed class TeamsConnectivityCheck : IDoctorCheck
|
||||
{
|
||||
private const string PluginId = "stellaops.doctor.notify";
|
||||
private const string CategoryName = "Notifications";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.notify.teams.connectivity";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Teams Connectivity";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verify Microsoft Teams webhook endpoint is reachable";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["notify", "teams", "connectivity", "network"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
var webhookUrl = context.Configuration["Notify:Channels:Teams:WebhookUrl"];
|
||||
return !string.IsNullOrWhiteSpace(webhookUrl) &&
|
||||
Uri.TryCreate(webhookUrl, UriKind.Absolute, out _);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var webhookUrl = context.Configuration["Notify:Channels:Teams:WebhookUrl"]!;
|
||||
var builder = context.CreateResult(CheckId, PluginId, CategoryName);
|
||||
|
||||
try
|
||||
{
|
||||
var httpClientFactory = context.Services.GetRequiredService<IHttpClientFactory>();
|
||||
var httpClient = httpClientFactory.CreateClient("DoctorHealthCheck");
|
||||
httpClient.Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
// Teams Adaptive Card format for connectivity test
|
||||
// Using a minimal card that validates the endpoint
|
||||
var testPayload = new
|
||||
{
|
||||
type = "message",
|
||||
attachments = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
contentType = "application/vnd.microsoft.card.adaptive",
|
||||
contentUrl = (string?)null,
|
||||
content = new
|
||||
{
|
||||
type = "AdaptiveCard",
|
||||
body = Array.Empty<object>(),
|
||||
version = "1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var content = new StringContent(
|
||||
JsonSerializer.Serialize(testPayload),
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
|
||||
var response = await httpClient.PostAsync(webhookUrl, content, ct);
|
||||
var responseBody = await response.Content.ReadAsStringAsync(ct);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return builder
|
||||
.Pass("Teams webhook endpoint is reachable")
|
||||
.WithEvidence("Teams connectivity test", eb => eb
|
||||
.Add("WebhookUrl", DoctorPluginContext.Redact(webhookUrl))
|
||||
.Add("StatusCode", ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture))
|
||||
.Add("Response", responseBody.Length > 100 ? responseBody[..100] + "..." : responseBody))
|
||||
.Build();
|
||||
}
|
||||
|
||||
return builder
|
||||
.Warn($"Teams webhook returned unexpected response: {response.StatusCode}")
|
||||
.WithEvidence("Teams connectivity test", eb => eb
|
||||
.Add("WebhookUrl", DoctorPluginContext.Redact(webhookUrl))
|
||||
.Add("StatusCode", ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture))
|
||||
.Add("Response", responseBody.Length > 200 ? responseBody[..200] + "..." : responseBody))
|
||||
.WithCauses(
|
||||
"Invalid or expired webhook URL",
|
||||
"Teams connector disabled or deleted",
|
||||
"Webhook URL revoked",
|
||||
"Microsoft 365 tenant configuration changed")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Verify webhook in Teams",
|
||||
"# Go to Teams channel > Connectors > Configured > Incoming Webhook",
|
||||
CommandType.Manual)
|
||||
.AddStep(2, "Test webhook manually",
|
||||
$"curl -H 'Content-Type: application/json' -d '{{\"text\":\"Doctor test\"}}' '{DoctorPluginContext.Redact(webhookUrl)}'",
|
||||
CommandType.Shell)
|
||||
.AddStep(3, "Recreate webhook if needed",
|
||||
"# Delete and recreate the Incoming Webhook connector in Teams",
|
||||
CommandType.Manual))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return builder
|
||||
.Fail("Teams webhook connection timed out")
|
||||
.WithEvidence("Teams connectivity test", eb => eb
|
||||
.Add("WebhookUrl", DoctorPluginContext.Redact(webhookUrl))
|
||||
.Add("Error", "Connection timeout (10s)"))
|
||||
.WithCauses(
|
||||
"Network connectivity issue to Microsoft",
|
||||
"Firewall blocking outbound HTTPS",
|
||||
"Proxy configuration required",
|
||||
"Microsoft 365 service degradation")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Check network connectivity",
|
||||
"curl -v https://webhook.office.com/",
|
||||
CommandType.Shell)
|
||||
.AddStep(2, "Check Microsoft 365 status",
|
||||
"# Visit https://status.office.com for service status",
|
||||
CommandType.Manual)
|
||||
.AddStep(3, "Verify proxy settings if applicable",
|
||||
"echo $HTTP_PROXY $HTTPS_PROXY",
|
||||
CommandType.Shell))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return builder
|
||||
.Fail($"Cannot reach Teams webhook: {ex.Message}")
|
||||
.WithEvidence("Teams connectivity test", eb => eb
|
||||
.Add("WebhookUrl", DoctorPluginContext.Redact(webhookUrl))
|
||||
.Add("Error", ex.Message))
|
||||
.WithCauses(
|
||||
"DNS resolution failure",
|
||||
"Network connectivity issue",
|
||||
"TLS/SSL certificate problem",
|
||||
"Firewall blocking connection")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Test DNS resolution",
|
||||
"nslookup webhook.office.com",
|
||||
CommandType.Shell)
|
||||
.AddStep(2, "Test HTTPS connectivity",
|
||||
"curl -v https://webhook.office.com/",
|
||||
CommandType.Shell))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugin.Notify.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if generic webhook notification channels are properly configured.
|
||||
/// </summary>
|
||||
public sealed class WebhookConfiguredCheck : IDoctorCheck
|
||||
{
|
||||
private const string PluginId = "stellaops.doctor.notify";
|
||||
private const string CategoryName = "Notifications";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.notify.webhook.configured";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Webhook Configuration";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verify generic webhook notification channel is properly configured";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["notify", "webhook", "quick", "configuration"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromMilliseconds(100);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
var webhookConfig = context.Configuration.GetSection("Notify:Channels:Webhook");
|
||||
return webhookConfig.Exists();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var builder = context.CreateResult(CheckId, PluginId, CategoryName);
|
||||
var webhookConfig = context.Configuration.GetSection("Notify:Channels:Webhook");
|
||||
|
||||
var url = webhookConfig["Url"] ?? webhookConfig["Endpoint"];
|
||||
var enabled = webhookConfig.GetValue<bool>("Enabled", true);
|
||||
var method = webhookConfig["Method"] ?? "POST";
|
||||
var contentType = webhookConfig["ContentType"] ?? "application/json";
|
||||
|
||||
var hasUrl = !string.IsNullOrWhiteSpace(url);
|
||||
var isValidUrl = hasUrl && Uri.TryCreate(url, UriKind.Absolute, out var uri) &&
|
||||
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
|
||||
|
||||
if (!hasUrl)
|
||||
{
|
||||
return Task.FromResult(builder
|
||||
.Fail("Webhook URL is not configured")
|
||||
.WithEvidence("Webhook configuration status", eb => eb
|
||||
.Add("Url", "(not set)")
|
||||
.Add("Enabled", enabled.ToString())
|
||||
.Add("Method", method)
|
||||
.Add("ContentType", contentType))
|
||||
.WithCauses(
|
||||
"Webhook URL not set in configuration",
|
||||
"Missing Notify:Channels:Webhook:Url setting",
|
||||
"Environment variable not bound to configuration")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Add webhook URL to configuration",
|
||||
"# Add to appsettings.json:\n" +
|
||||
"# \"Notify\": { \"Channels\": { \"Webhook\": { \"Url\": \"https://your-endpoint/webhook\" } } }",
|
||||
CommandType.FileEdit)
|
||||
.AddStep(2, "Or set via environment variable",
|
||||
"export Notify__Channels__Webhook__Url=\"https://your-endpoint/webhook\"",
|
||||
CommandType.Shell))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build());
|
||||
}
|
||||
|
||||
if (!isValidUrl)
|
||||
{
|
||||
return Task.FromResult(builder
|
||||
.Fail("Webhook URL format is invalid")
|
||||
.WithEvidence("Webhook configuration status", eb => eb
|
||||
.Add("Url", url!)
|
||||
.Add("Enabled", enabled.ToString())
|
||||
.Add("ValidationError", "URL must be a valid HTTP or HTTPS URL"))
|
||||
.WithCauses(
|
||||
"Malformed URL in configuration",
|
||||
"Missing protocol (http:// or https://)",
|
||||
"Invalid characters in URL")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Fix URL format",
|
||||
"# Ensure URL starts with http:// or https:// and is properly encoded",
|
||||
CommandType.Manual))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build());
|
||||
}
|
||||
|
||||
if (!enabled)
|
||||
{
|
||||
return Task.FromResult(builder
|
||||
.Warn("Webhook channel is configured but disabled")
|
||||
.WithEvidence("Webhook configuration status", eb => eb
|
||||
.Add("Url", DoctorPluginContext.Redact(url))
|
||||
.Add("Enabled", "false")
|
||||
.Add("Method", method)
|
||||
.Add("ContentType", contentType))
|
||||
.WithCauses(
|
||||
"Webhook notifications explicitly disabled in configuration")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Enable webhook notifications",
|
||||
"# Set Notify:Channels:Webhook:Enabled to true in configuration",
|
||||
CommandType.FileEdit))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build());
|
||||
}
|
||||
|
||||
return Task.FromResult(builder
|
||||
.Pass("Webhook notification channel is properly configured")
|
||||
.WithEvidence("Webhook configuration status", eb => eb
|
||||
.Add("Url", DoctorPluginContext.Redact(url))
|
||||
.Add("Enabled", "true")
|
||||
.Add("Method", method)
|
||||
.Add("ContentType", contentType))
|
||||
.Build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using System.Globalization;
|
||||
using System.Net.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugin.Notify.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the configured webhook endpoint is reachable.
|
||||
/// </summary>
|
||||
public sealed class WebhookConnectivityCheck : IDoctorCheck
|
||||
{
|
||||
private const string PluginId = "stellaops.doctor.notify";
|
||||
private const string CategoryName = "Notifications";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.notify.webhook.connectivity";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Webhook Connectivity";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verify generic webhook endpoint is reachable";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["notify", "webhook", "connectivity", "network"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
var url = context.Configuration["Notify:Channels:Webhook:Url"] ??
|
||||
context.Configuration["Notify:Channels:Webhook:Endpoint"];
|
||||
return !string.IsNullOrWhiteSpace(url) &&
|
||||
Uri.TryCreate(url, UriKind.Absolute, out _);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var url = context.Configuration["Notify:Channels:Webhook:Url"] ??
|
||||
context.Configuration["Notify:Channels:Webhook:Endpoint"]!;
|
||||
var builder = context.CreateResult(CheckId, PluginId, CategoryName);
|
||||
|
||||
try
|
||||
{
|
||||
var httpClientFactory = context.Services.GetRequiredService<IHttpClientFactory>();
|
||||
var httpClient = httpClientFactory.CreateClient("DoctorHealthCheck");
|
||||
httpClient.Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
// Use HEAD request first to avoid side effects, fall back to GET
|
||||
var uri = new Uri(url);
|
||||
HttpResponseMessage? response = null;
|
||||
|
||||
try
|
||||
{
|
||||
var headRequest = new HttpRequestMessage(HttpMethod.Head, uri);
|
||||
response = await httpClient.SendAsync(headRequest, ct);
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
// HEAD might not be supported, try OPTIONS
|
||||
var optionsRequest = new HttpRequestMessage(HttpMethod.Options, uri);
|
||||
response = await httpClient.SendAsync(optionsRequest, ct);
|
||||
}
|
||||
|
||||
// For connectivity test, any response (even 4xx for auth required) means endpoint is reachable
|
||||
var isReachable = (int)response.StatusCode < 500;
|
||||
|
||||
if (isReachable)
|
||||
{
|
||||
var diagnosis = response.IsSuccessStatusCode
|
||||
? "Webhook endpoint is reachable and responding"
|
||||
: $"Webhook endpoint is reachable (status: {response.StatusCode})";
|
||||
|
||||
var severity = response.IsSuccessStatusCode ? DoctorSeverity.Pass : DoctorSeverity.Info;
|
||||
|
||||
return builder
|
||||
.WithSeverity(severity, diagnosis)
|
||||
.WithEvidence("Webhook connectivity test", eb => eb
|
||||
.Add("Url", DoctorPluginContext.Redact(url))
|
||||
.Add("StatusCode", ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture))
|
||||
.Add("TestMethod", "HEAD/OPTIONS")
|
||||
.Add("Note", response.IsSuccessStatusCode
|
||||
? "Endpoint responding normally"
|
||||
: "Endpoint reachable but may require authentication"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
return builder
|
||||
.Warn($"Webhook endpoint returned server error: {response.StatusCode}")
|
||||
.WithEvidence("Webhook connectivity test", eb => eb
|
||||
.Add("Url", DoctorPluginContext.Redact(url))
|
||||
.Add("StatusCode", ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture)))
|
||||
.WithCauses(
|
||||
"Webhook endpoint server is experiencing issues",
|
||||
"Endpoint service is down",
|
||||
"Backend service unavailable")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Check webhook endpoint status",
|
||||
$"curl -I {DoctorPluginContext.Redact(url)}",
|
||||
CommandType.Shell)
|
||||
.AddStep(2, "Verify endpoint service is running",
|
||||
"# Check the service hosting your webhook endpoint",
|
||||
CommandType.Manual)
|
||||
.AddStep(3, "Check endpoint logs",
|
||||
"# Review logs on the webhook endpoint server",
|
||||
CommandType.Manual))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return builder
|
||||
.Fail("Webhook endpoint connection timed out")
|
||||
.WithEvidence("Webhook connectivity test", eb => eb
|
||||
.Add("Url", DoctorPluginContext.Redact(url))
|
||||
.Add("Error", "Connection timeout (10s)"))
|
||||
.WithCauses(
|
||||
"Endpoint server not responding",
|
||||
"Network connectivity issue",
|
||||
"Firewall blocking connection",
|
||||
"DNS resolution slow or failing")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Test basic connectivity",
|
||||
$"curl -v --max-time 10 {DoctorPluginContext.Redact(url)}",
|
||||
CommandType.Shell)
|
||||
.AddStep(2, "Check DNS resolution",
|
||||
$"nslookup {new Uri(url).Host}",
|
||||
CommandType.Shell)
|
||||
.AddStep(3, "Test port connectivity",
|
||||
$"nc -zv {new Uri(url).Host} {(new Uri(url).Port > 0 ? new Uri(url).Port : (new Uri(url).Scheme == "https" ? 443 : 80))}",
|
||||
CommandType.Shell))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return builder
|
||||
.Fail($"Cannot reach webhook endpoint: {ex.Message}")
|
||||
.WithEvidence("Webhook connectivity test", eb => eb
|
||||
.Add("Url", DoctorPluginContext.Redact(url))
|
||||
.Add("Error", ex.Message))
|
||||
.WithCauses(
|
||||
"DNS resolution failure",
|
||||
"Network connectivity issue",
|
||||
"TLS/SSL certificate problem",
|
||||
"Invalid URL")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStep(1, "Test DNS resolution",
|
||||
$"nslookup {new Uri(url).Host}",
|
||||
CommandType.Shell)
|
||||
.AddStep(2, "Test connectivity",
|
||||
$"curl -v {DoctorPluginContext.Redact(url)}",
|
||||
CommandType.Shell))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Doctor.Plugin.Notify.Checks;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Notify.Engine;
|
||||
|
||||
namespace StellaOps.Doctor.Plugin.Notify;
|
||||
|
||||
/// <summary>
|
||||
/// Doctor plugin for notification channel diagnostics (Slack, Teams, Email, Webhooks, Queue).
|
||||
/// </summary>
|
||||
public sealed class NotifyDoctorPlugin : IDoctorPlugin
|
||||
{
|
||||
private static readonly Version PluginVersion = new(1, 0, 0);
|
||||
private static readonly Version MinVersion = new(1, 0, 0);
|
||||
|
||||
/// <inheritdoc />
|
||||
public string PluginId => "stellaops.doctor.notify";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => "Notifications";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorCategory Category => DoctorCategory.Notify;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Version Version => PluginVersion;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Version MinEngineVersion => MinVersion;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsAvailable(IServiceProvider services)
|
||||
{
|
||||
// Plugin is available if any notification health providers are registered
|
||||
var providers = services.GetService<IEnumerable<INotifyChannelHealthProvider>>();
|
||||
return providers?.Any() == true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<IDoctorCheck> GetChecks(DoctorPluginContext context)
|
||||
{
|
||||
return new IDoctorCheck[]
|
||||
{
|
||||
// Slack checks
|
||||
new SlackConfiguredCheck(),
|
||||
new SlackConnectivityCheck(),
|
||||
|
||||
// Teams checks
|
||||
new TeamsConfiguredCheck(),
|
||||
new TeamsConnectivityCheck(),
|
||||
|
||||
// Webhook checks
|
||||
new WebhookConfiguredCheck(),
|
||||
new WebhookConnectivityCheck(),
|
||||
|
||||
// Email checks
|
||||
new EmailConfiguredCheck(),
|
||||
new EmailConnectivityCheck(),
|
||||
|
||||
// Queue health
|
||||
new NotifyQueueHealthCheck()
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InitializeAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
// No initialization required
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Doctor.Plugin.Notify</RootNamespace>
|
||||
<Description>Notification channel checks for Stella Ops Doctor diagnostics - Slack, Teams, Email, Webhooks, Queue</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Doctor\StellaOps.Doctor.csproj" />
|
||||
<ProjectReference Include="..\..\..\Notify\__Libraries\StellaOps.Notify.Engine\StellaOps.Notify.Engine.csproj" />
|
||||
<ProjectReference Include="..\..\..\Notify\__Libraries\StellaOps.Notify.Models\StellaOps.Notify.Models.csproj" />
|
||||
<ProjectReference Include="..\..\..\Notify\__Libraries\StellaOps.Notify.Queue\StellaOps.Notify.Queue.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
70
src/Doctor/__Plugins/StellaOps.Doctor.Plugin.Notify/TASKS.md
Normal file
70
src/Doctor/__Plugins/StellaOps.Doctor.Plugin.Notify/TASKS.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# StellaOps.Doctor.Plugin.Notify
|
||||
|
||||
## Overview
|
||||
|
||||
Doctor plugin for notification channel diagnostics - validates and tests Slack, Teams, Email, Webhook, and Queue configurations.
|
||||
|
||||
## Checks
|
||||
|
||||
| Check ID | Name | Description | Severity |
|
||||
|----------|------|-------------|----------|
|
||||
| `check.notify.slack.configured` | Slack Configuration | Validates Slack webhook URL and settings | Warn |
|
||||
| `check.notify.slack.connectivity` | Slack Connectivity | Tests actual connectivity to Slack webhook | Warn |
|
||||
| `check.notify.teams.configured` | Teams Configuration | Validates Teams webhook URL and settings | Warn |
|
||||
| `check.notify.teams.connectivity` | Teams Connectivity | Tests actual connectivity to Teams webhook | Warn |
|
||||
| `check.notify.webhook.configured` | Webhook Configuration | Validates generic webhook URL and settings | Warn |
|
||||
| `check.notify.webhook.connectivity` | Webhook Connectivity | Tests actual connectivity to webhook endpoint | Warn |
|
||||
| `check.notify.email.configured` | Email Configuration | Validates SMTP host, port, and sender settings | Warn |
|
||||
| `check.notify.email.connectivity` | Email Connectivity | Tests TCP connectivity to SMTP server | Warn |
|
||||
| `check.notify.queue.health` | Queue Health | Wraps existing Notify queue health checks | Critical |
|
||||
|
||||
## Configuration Paths
|
||||
|
||||
### Slack
|
||||
- `Notify:Channels:Slack:WebhookUrl` - Slack incoming webhook URL
|
||||
- `Notify:Channels:Slack:Enabled` - Enable/disable channel
|
||||
- `Notify:Channels:Slack:Channel` - Default channel override
|
||||
|
||||
### Teams
|
||||
- `Notify:Channels:Teams:WebhookUrl` - Teams incoming webhook URL
|
||||
- `Notify:Channels:Teams:Enabled` - Enable/disable channel
|
||||
|
||||
### Webhook
|
||||
- `Notify:Channels:Webhook:Url` or `Endpoint` - Webhook endpoint URL
|
||||
- `Notify:Channels:Webhook:Enabled` - Enable/disable channel
|
||||
- `Notify:Channels:Webhook:Method` - HTTP method (default: POST)
|
||||
- `Notify:Channels:Webhook:ContentType` - Content type (default: application/json)
|
||||
|
||||
### Email
|
||||
- `Notify:Channels:Email:SmtpHost` or `Host` - SMTP server hostname
|
||||
- `Notify:Channels:Email:SmtpPort` or `Port` - SMTP port (25/465/587)
|
||||
- `Notify:Channels:Email:FromAddress` or `From` - Sender email address
|
||||
- `Notify:Channels:Email:Enabled` - Enable/disable channel
|
||||
- `Notify:Channels:Email:UseSsl` - Use SSL/TLS
|
||||
- `Notify:Channels:Email:Username` - SMTP credentials
|
||||
|
||||
### Queue
|
||||
- `Notify:Queue:Transport` or `Kind` - Queue transport type (redis/nats)
|
||||
- `Notify:Queue:Redis:ConnectionString` - Redis connection string
|
||||
- `Notify:Queue:Nats:Url` - NATS server URL
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `StellaOps.Doctor` - Core Doctor plugin infrastructure
|
||||
- `StellaOps.Notify.Engine` - Notify channel health provider interfaces
|
||||
- `StellaOps.Notify.Models` - Notify data models
|
||||
- `StellaOps.Notify.Queue` - Queue health check implementations
|
||||
|
||||
## Status
|
||||
|
||||
- [x] Plugin skeleton
|
||||
- [x] Slack configuration check
|
||||
- [x] Slack connectivity check
|
||||
- [x] Teams configuration check
|
||||
- [x] Teams connectivity check
|
||||
- [x] Webhook configuration check
|
||||
- [x] Webhook connectivity check
|
||||
- [x] Email configuration check
|
||||
- [x] Email connectivity check
|
||||
- [x] Queue health check wrapper
|
||||
- [x] Unit tests
|
||||
@@ -11,7 +11,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\__Libraries\StellaOps.Doctor\StellaOps.Doctor.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Doctor\StellaOps.Doctor.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugin.Notify.Checks;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Plugin.Notify.Tests.Checks;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class EmailConfiguredCheckTests
|
||||
{
|
||||
private readonly EmailConfiguredCheck _check = new();
|
||||
|
||||
[Fact]
|
||||
public void CheckId_ReturnsExpectedValue()
|
||||
{
|
||||
// Assert
|
||||
_check.CheckId.Should().Be("check.notify.email.configured");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsFalse_WhenEmailNotConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>());
|
||||
|
||||
// Act & Assert
|
||||
_check.CanRun(context).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsTrue_WhenEmailSectionExists()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:SmtpHost"] = "smtp.example.com"
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
_check.CanRun(context).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Fails_WhenSmtpHostNotSet()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:SmtpPort"] = "587"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("host");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Warns_WhenSmtpPortInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:SmtpHost"] = "smtp.example.com",
|
||||
["Notify:Channels:Email:SmtpPort"] = "0",
|
||||
["Notify:Channels:Email:FromAddress"] = "noreply@example.com"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
result.Diagnosis.Should().Contain("port");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Warns_WhenFromAddressMissing()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:SmtpHost"] = "smtp.example.com",
|
||||
["Notify:Channels:Email:SmtpPort"] = "587"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
result.Diagnosis.Should().Contain("From");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Warns_WhenDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:SmtpHost"] = "smtp.example.com",
|
||||
["Notify:Channels:Email:SmtpPort"] = "587",
|
||||
["Notify:Channels:Email:FromAddress"] = "noreply@example.com",
|
||||
["Notify:Channels:Email:Enabled"] = "false"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
result.Diagnosis.Should().Contain("disabled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Passes_WhenProperlyConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:SmtpHost"] = "smtp.example.com",
|
||||
["Notify:Channels:Email:SmtpPort"] = "587",
|
||||
["Notify:Channels:Email:FromAddress"] = "noreply@example.com",
|
||||
["Notify:Channels:Email:Enabled"] = "true",
|
||||
["Notify:Channels:Email:UseSsl"] = "true"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_SupportsAlternativeHostKey()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Email:Host"] = "smtp.example.com",
|
||||
["Notify:Channels:Email:Port"] = "587",
|
||||
["Notify:Channels:Email:From"] = "noreply@example.com"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_ContainsExpectedValues()
|
||||
{
|
||||
// Assert
|
||||
_check.Tags.Should().Contain("notify");
|
||||
_check.Tags.Should().Contain("email");
|
||||
_check.Tags.Should().Contain("smtp");
|
||||
_check.Tags.Should().Contain("configuration");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultSeverity_IsWarn()
|
||||
{
|
||||
// Assert
|
||||
_check.DefaultSeverity.Should().Be(DoctorSeverity.Warn);
|
||||
}
|
||||
|
||||
private static DoctorPluginContext CreateContext(Dictionary<string, string?> configValues)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(configValues)
|
||||
.Build();
|
||||
|
||||
return new DoctorPluginContext
|
||||
{
|
||||
Services = new ServiceCollection().BuildServiceProvider(),
|
||||
Configuration = config,
|
||||
TimeProvider = TimeProvider.System,
|
||||
Logger = NullLogger.Instance,
|
||||
EnvironmentName = "Test",
|
||||
PluginConfig = config.GetSection("Doctor:Plugins")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugin.Notify.Checks;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Plugin.Notify.Tests.Checks;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class NotifyQueueHealthCheckTests
|
||||
{
|
||||
private readonly NotifyQueueHealthCheck _check = new();
|
||||
|
||||
[Fact]
|
||||
public void CheckId_ReturnsExpectedValue()
|
||||
{
|
||||
// Assert
|
||||
_check.CheckId.Should().Be("check.notify.queue.health");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsFalse_WhenQueueNotConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>());
|
||||
|
||||
// Act & Assert
|
||||
_check.CanRun(context).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsTrue_WhenQueueTransportConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Queue:Transport"] = "redis"
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
_check.CanRun(context).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsTrue_WhenQueueKindConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Queue:Kind"] = "nats"
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
_check.CanRun(context).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Skips_WhenNoHealthChecksRegistered()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Queue:Transport"] = "redis"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Skip);
|
||||
result.Diagnosis.Should().Contain("registered");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_ContainsExpectedValues()
|
||||
{
|
||||
// Assert
|
||||
_check.Tags.Should().Contain("notify");
|
||||
_check.Tags.Should().Contain("queue");
|
||||
_check.Tags.Should().Contain("redis");
|
||||
_check.Tags.Should().Contain("nats");
|
||||
_check.Tags.Should().Contain("infrastructure");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultSeverity_IsFail()
|
||||
{
|
||||
// Assert
|
||||
_check.DefaultSeverity.Should().Be(DoctorSeverity.Fail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EstimatedDuration_IsReasonable()
|
||||
{
|
||||
// Assert
|
||||
_check.EstimatedDuration.Should().BeGreaterThan(TimeSpan.Zero);
|
||||
_check.EstimatedDuration.Should().BeLessThanOrEqualTo(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_IsNotEmpty()
|
||||
{
|
||||
// Assert
|
||||
_check.Name.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Description_IsNotEmpty()
|
||||
{
|
||||
// Assert
|
||||
_check.Description.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
private static DoctorPluginContext CreateContext(Dictionary<string, string?> configValues)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(configValues)
|
||||
.Build();
|
||||
|
||||
return new DoctorPluginContext
|
||||
{
|
||||
Services = new ServiceCollection().BuildServiceProvider(),
|
||||
Configuration = config,
|
||||
TimeProvider = TimeProvider.System,
|
||||
Logger = NullLogger.Instance,
|
||||
EnvironmentName = "Test",
|
||||
PluginConfig = config.GetSection("Doctor:Plugins")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugin.Notify.Checks;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Plugin.Notify.Tests.Checks;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class SlackConfiguredCheckTests
|
||||
{
|
||||
private readonly SlackConfiguredCheck _check = new();
|
||||
|
||||
[Fact]
|
||||
public void CheckId_ReturnsExpectedValue()
|
||||
{
|
||||
// Assert
|
||||
_check.CheckId.Should().Be("check.notify.slack.configured");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsFalse_WhenSlackNotConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>());
|
||||
|
||||
// Act & Assert
|
||||
_check.CanRun(context).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsTrue_WhenSlackSectionExists()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Slack:WebhookUrl"] = "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXX"
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
_check.CanRun(context).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Fails_WhenWebhookUrlNotSet()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Slack:Enabled"] = "true"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("not configured");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Passes_WhenWebhookUrlSet()
|
||||
{
|
||||
// Arrange - note: SlackConfiguredCheck doesn't validate URL format, only presence
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Slack:WebhookUrl"] = "any-non-empty-value"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert - passes because webhook URL is set (format validation is done by connectivity check)
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Warns_WhenDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Slack:WebhookUrl"] = "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXX",
|
||||
["Notify:Channels:Slack:Enabled"] = "false"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
result.Diagnosis.Should().Contain("disabled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Passes_WhenProperlyConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Slack:WebhookUrl"] = "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXX",
|
||||
["Notify:Channels:Slack:Enabled"] = "true",
|
||||
["Notify:Channels:Slack:Channel"] = "#alerts"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_ContainsExpectedValues()
|
||||
{
|
||||
// Assert
|
||||
_check.Tags.Should().Contain("notify");
|
||||
_check.Tags.Should().Contain("slack");
|
||||
_check.Tags.Should().Contain("configuration");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultSeverity_IsWarn()
|
||||
{
|
||||
// Assert
|
||||
_check.DefaultSeverity.Should().Be(DoctorSeverity.Warn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EstimatedDuration_IsQuick()
|
||||
{
|
||||
// Assert
|
||||
_check.EstimatedDuration.Should().BeLessThanOrEqualTo(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
private static DoctorPluginContext CreateContext(Dictionary<string, string?> configValues)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(configValues)
|
||||
.Build();
|
||||
|
||||
return new DoctorPluginContext
|
||||
{
|
||||
Services = new ServiceCollection().BuildServiceProvider(),
|
||||
Configuration = config,
|
||||
TimeProvider = TimeProvider.System,
|
||||
Logger = NullLogger.Instance,
|
||||
EnvironmentName = "Test",
|
||||
PluginConfig = config.GetSection("Doctor:Plugins")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugin.Notify.Checks;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Plugin.Notify.Tests.Checks;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class TeamsConfiguredCheckTests
|
||||
{
|
||||
private readonly TeamsConfiguredCheck _check = new();
|
||||
|
||||
[Fact]
|
||||
public void CheckId_ReturnsExpectedValue()
|
||||
{
|
||||
// Assert
|
||||
_check.CheckId.Should().Be("check.notify.teams.configured");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsFalse_WhenTeamsNotConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>());
|
||||
|
||||
// Act & Assert
|
||||
_check.CanRun(context).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsTrue_WhenTeamsSectionExists()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Teams:WebhookUrl"] = "https://webhook.office.com/..."
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
_check.CanRun(context).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Fails_WhenWebhookUrlNotSet()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Teams:Enabled"] = "true"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Warns_WhenNotOfficeComDomain()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Teams:WebhookUrl"] = "https://example.com/webhook"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
result.Diagnosis.Should().Contain("invalid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Warns_WhenDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Teams:WebhookUrl"] = "https://webhook.office.com/webhookb2/xxx",
|
||||
["Notify:Channels:Teams:Enabled"] = "false"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
result.Diagnosis.Should().Contain("disabled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Passes_WhenProperlyConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Teams:WebhookUrl"] = "https://webhook.office.com/webhookb2/xxx@xxx/IncomingWebhook/xxx/xxx",
|
||||
["Notify:Channels:Teams:Enabled"] = "true"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_ContainsExpectedValues()
|
||||
{
|
||||
// Assert
|
||||
_check.Tags.Should().Contain("notify");
|
||||
_check.Tags.Should().Contain("teams");
|
||||
_check.Tags.Should().Contain("configuration");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultSeverity_IsWarn()
|
||||
{
|
||||
// Assert
|
||||
_check.DefaultSeverity.Should().Be(DoctorSeverity.Warn);
|
||||
}
|
||||
|
||||
private static DoctorPluginContext CreateContext(Dictionary<string, string?> configValues)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(configValues)
|
||||
.Build();
|
||||
|
||||
return new DoctorPluginContext
|
||||
{
|
||||
Services = new ServiceCollection().BuildServiceProvider(),
|
||||
Configuration = config,
|
||||
TimeProvider = TimeProvider.System,
|
||||
Logger = NullLogger.Instance,
|
||||
EnvironmentName = "Test",
|
||||
PluginConfig = config.GetSection("Doctor:Plugins")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugin.Notify.Checks;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Plugin.Notify.Tests.Checks;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class WebhookConfiguredCheckTests
|
||||
{
|
||||
private readonly WebhookConfiguredCheck _check = new();
|
||||
|
||||
[Fact]
|
||||
public void CheckId_ReturnsExpectedValue()
|
||||
{
|
||||
// Assert
|
||||
_check.CheckId.Should().Be("check.notify.webhook.configured");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsFalse_WhenWebhookNotConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>());
|
||||
|
||||
// Act & Assert
|
||||
_check.CanRun(context).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsTrue_WhenWebhookSectionExists()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Webhook:Url"] = "https://example.com/webhook"
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
_check.CanRun(context).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Fails_WhenUrlNotSet()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Webhook:Enabled"] = "true"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("URL");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Fails_WhenUrlInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Webhook:Url"] = "not-a-valid-url"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("format");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Warns_WhenDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Webhook:Url"] = "https://example.com/webhook",
|
||||
["Notify:Channels:Webhook:Enabled"] = "false"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
result.Diagnosis.Should().Contain("disabled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_Passes_WhenProperlyConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Webhook:Url"] = "https://example.com/webhook",
|
||||
["Notify:Channels:Webhook:Enabled"] = "true",
|
||||
["Notify:Channels:Webhook:Method"] = "POST"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_SupportsEndpointAlternativeKey()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(new Dictionary<string, string?>
|
||||
{
|
||||
["Notify:Channels:Webhook:Endpoint"] = "https://example.com/webhook"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_ContainsExpectedValues()
|
||||
{
|
||||
// Assert
|
||||
_check.Tags.Should().Contain("notify");
|
||||
_check.Tags.Should().Contain("webhook");
|
||||
_check.Tags.Should().Contain("configuration");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultSeverity_IsWarn()
|
||||
{
|
||||
// Assert
|
||||
_check.DefaultSeverity.Should().Be(DoctorSeverity.Warn);
|
||||
}
|
||||
|
||||
private static DoctorPluginContext CreateContext(Dictionary<string, string?> configValues)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(configValues)
|
||||
.Build();
|
||||
|
||||
return new DoctorPluginContext
|
||||
{
|
||||
Services = new ServiceCollection().BuildServiceProvider(),
|
||||
Configuration = config,
|
||||
TimeProvider = TimeProvider.System,
|
||||
Logger = NullLogger.Instance,
|
||||
EnvironmentName = "Test",
|
||||
PluginConfig = config.GetSection("Doctor:Plugins")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using StellaOps.Notify.Engine;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Plugin.Notify.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class NotifyDoctorPluginTests
|
||||
{
|
||||
private readonly NotifyDoctorPlugin _plugin = new();
|
||||
|
||||
[Fact]
|
||||
public void PluginId_ReturnsExpectedValue()
|
||||
{
|
||||
// Assert
|
||||
_plugin.PluginId.Should().Be("stellaops.doctor.notify");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Category_IsNotify()
|
||||
{
|
||||
// Assert
|
||||
_plugin.Category.Should().Be(DoctorCategory.Notify);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisplayName_IsNotifications()
|
||||
{
|
||||
// Assert
|
||||
_plugin.DisplayName.Should().Be("Notifications");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsAvailable_ReturnsFalse_WhenNoHealthProvidersRegistered()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
|
||||
// Act & Assert
|
||||
_plugin.IsAvailable(services).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsAvailable_ReturnsTrue_WhenHealthProvidersRegistered()
|
||||
{
|
||||
// Arrange
|
||||
var mockProvider = new Mock<INotifyChannelHealthProvider>();
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton(mockProvider.Object)
|
||||
.BuildServiceProvider();
|
||||
|
||||
// Act & Assert
|
||||
_plugin.IsAvailable(services).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetChecks_ReturnsNineChecks()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext();
|
||||
|
||||
// Act
|
||||
var checks = _plugin.GetChecks(context);
|
||||
|
||||
// Assert
|
||||
checks.Should().HaveCount(9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetChecks_ContainsSlackChecks()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext();
|
||||
|
||||
// Act
|
||||
var checks = _plugin.GetChecks(context);
|
||||
|
||||
// Assert
|
||||
checks.Select(c => c.CheckId).Should().Contain("check.notify.slack.configured");
|
||||
checks.Select(c => c.CheckId).Should().Contain("check.notify.slack.connectivity");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetChecks_ContainsTeamsChecks()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext();
|
||||
|
||||
// Act
|
||||
var checks = _plugin.GetChecks(context);
|
||||
|
||||
// Assert
|
||||
checks.Select(c => c.CheckId).Should().Contain("check.notify.teams.configured");
|
||||
checks.Select(c => c.CheckId).Should().Contain("check.notify.teams.connectivity");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetChecks_ContainsWebhookChecks()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext();
|
||||
|
||||
// Act
|
||||
var checks = _plugin.GetChecks(context);
|
||||
|
||||
// Assert
|
||||
checks.Select(c => c.CheckId).Should().Contain("check.notify.webhook.configured");
|
||||
checks.Select(c => c.CheckId).Should().Contain("check.notify.webhook.connectivity");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetChecks_ContainsEmailChecks()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext();
|
||||
|
||||
// Act
|
||||
var checks = _plugin.GetChecks(context);
|
||||
|
||||
// Assert
|
||||
checks.Select(c => c.CheckId).Should().Contain("check.notify.email.configured");
|
||||
checks.Select(c => c.CheckId).Should().Contain("check.notify.email.connectivity");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetChecks_ContainsQueueHealthCheck()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext();
|
||||
|
||||
// Act
|
||||
var checks = _plugin.GetChecks(context);
|
||||
|
||||
// Assert
|
||||
checks.Select(c => c.CheckId).Should().Contain("check.notify.queue.health");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeAsync_CompletesWithoutError()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext();
|
||||
|
||||
// Act & Assert
|
||||
await _plugin.Invoking(p => p.InitializeAsync(context, CancellationToken.None))
|
||||
.Should().NotThrowAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Version_IsNotNull()
|
||||
{
|
||||
// Assert
|
||||
_plugin.Version.Should().NotBeNull();
|
||||
_plugin.Version.Major.Should().BeGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
private static DoctorPluginContext CreateContext()
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>())
|
||||
.Build();
|
||||
|
||||
return new DoctorPluginContext
|
||||
{
|
||||
Services = new ServiceCollection().BuildServiceProvider(),
|
||||
Configuration = config,
|
||||
TimeProvider = TimeProvider.System,
|
||||
Logger = NullLogger.Instance,
|
||||
EnvironmentName = "Test",
|
||||
PluginConfig = config.GetSection("Doctor:Plugins")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="coverlet.collector">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Plugins\StellaOps.Doctor.Plugin.Notify\StellaOps.Doctor.Plugin.Notify.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,80 @@
|
||||
# StellaOps.Doctor.Plugin.Notify.Tests
|
||||
|
||||
## Overview
|
||||
|
||||
Unit tests for the Notification Doctor Plugin that validates Slack, Teams, Email, Webhook, and Queue configurations.
|
||||
|
||||
## Test Coverage
|
||||
|
||||
### Plugin Tests
|
||||
- [x] PluginId validation
|
||||
- [x] Category is Notify
|
||||
- [x] DisplayName is Notifications
|
||||
- [x] IsAvailable returns false when no health providers registered
|
||||
- [x] IsAvailable returns true when health providers registered
|
||||
- [x] GetChecks returns all nine checks
|
||||
- [x] InitializeAsync completes without error
|
||||
- [x] Version validation
|
||||
|
||||
### SlackConfiguredCheck Tests
|
||||
- [x] CheckId validation
|
||||
- [x] CanRun returns false when not configured
|
||||
- [x] CanRun returns true when section exists
|
||||
- [x] Fails when WebhookUrl not set
|
||||
- [x] Fails when WebhookUrl invalid
|
||||
- [x] Warns when disabled
|
||||
- [x] Passes when properly configured
|
||||
- [x] Tags validation
|
||||
- [x] DefaultSeverity is Warn
|
||||
|
||||
### TeamsConfiguredCheck Tests
|
||||
- [x] CheckId validation
|
||||
- [x] CanRun returns false when not configured
|
||||
- [x] CanRun returns true when section exists
|
||||
- [x] Fails when WebhookUrl not set
|
||||
- [x] Warns when not webhook.office.com domain
|
||||
- [x] Warns when disabled
|
||||
- [x] Passes when properly configured
|
||||
- [x] Tags validation
|
||||
- [x] DefaultSeverity is Warn
|
||||
|
||||
### WebhookConfiguredCheck Tests
|
||||
- [x] CheckId validation
|
||||
- [x] CanRun returns false when not configured
|
||||
- [x] CanRun returns true when section exists
|
||||
- [x] Fails when URL not set
|
||||
- [x] Fails when URL invalid
|
||||
- [x] Warns when disabled
|
||||
- [x] Passes when properly configured
|
||||
- [x] Supports Endpoint alternative key
|
||||
- [x] Tags validation
|
||||
- [x] DefaultSeverity is Warn
|
||||
|
||||
### EmailConfiguredCheck Tests
|
||||
- [x] CheckId validation
|
||||
- [x] CanRun returns false when not configured
|
||||
- [x] CanRun returns true when section exists
|
||||
- [x] Fails when SmtpHost not set
|
||||
- [x] Warns when SmtpPort invalid
|
||||
- [x] Warns when FromAddress missing
|
||||
- [x] Warns when disabled
|
||||
- [x] Passes when properly configured
|
||||
- [x] Supports alternative Host/Port/From keys
|
||||
- [x] Tags validation
|
||||
- [x] DefaultSeverity is Warn
|
||||
|
||||
### NotifyQueueHealthCheck Tests
|
||||
- [x] CheckId validation
|
||||
- [x] CanRun returns false when not configured
|
||||
- [x] CanRun returns true when Transport configured
|
||||
- [x] CanRun returns true when Kind configured
|
||||
- [x] Skips when no health checks registered
|
||||
- [x] Tags validation
|
||||
- [x] DefaultSeverity is Critical
|
||||
- [x] EstimatedDuration validation
|
||||
|
||||
## Future Work
|
||||
|
||||
- [ ] Integration tests with actual SMTP server (Testcontainers)
|
||||
- [ ] Integration tests with actual Redis/NATS (Testcontainers)
|
||||
- [ ] Mock HTTP handler tests for connectivity checks
|
||||
Reference in New Issue
Block a user