product advisories, stella router improval, tests streghthening
This commit is contained in:
@@ -0,0 +1,714 @@
|
||||
// ---------------------------------------------------------------------
|
||||
// <copyright file="EmailConnectorErrorTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
// <summary>
|
||||
// Error handling tests for email connector: SMTP unavailable → retry;
|
||||
// invalid recipient → fail gracefully.
|
||||
// </summary>
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Mail;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Email.Tests.ErrorHandling;
|
||||
|
||||
/// <summary>
|
||||
/// Error handling tests for email connector.
|
||||
/// Verifies graceful handling of SMTP failures and invalid recipients.
|
||||
/// </summary>
|
||||
[Trait("Category", "ErrorHandling")]
|
||||
[Trait("Sprint", "5100-0009-0009")]
|
||||
public sealed class EmailConnectorErrorTests
|
||||
{
|
||||
#region SMTP Unavailable Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that SMTP connection failure triggers retry behavior.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SmtpUnavailable_TriggersRetry()
|
||||
{
|
||||
// Arrange
|
||||
var smtpClient = new FailingSmtpClient(
|
||||
new SmtpException(SmtpStatusCode.ServiceNotAvailable, "Connection refused"));
|
||||
var connector = new EmailConnector(smtpClient, new EmailConnectorOptions
|
||||
{
|
||||
MaxRetries = 3,
|
||||
RetryDelayMs = 100
|
||||
});
|
||||
var notification = CreateTestNotification();
|
||||
|
||||
// Act
|
||||
var result = await connector.SendAsync(notification, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.ShouldRetry.Should().BeTrue("SMTP unavailable is a transient failure");
|
||||
result.RetryAfterMs.Should().BeGreaterThan(0);
|
||||
smtpClient.SendAttempts.Should().Be(1, "should not retry internally, let caller handle");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that SMTP timeout triggers retry behavior.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SmtpTimeout_TriggersRetry()
|
||||
{
|
||||
// Arrange
|
||||
var smtpClient = new FailingSmtpClient(
|
||||
new SmtpException(SmtpStatusCode.GeneralFailure, "Connection timed out"));
|
||||
var connector = new EmailConnector(smtpClient, new EmailConnectorOptions
|
||||
{
|
||||
TimeoutMs = 5000
|
||||
});
|
||||
var notification = CreateTestNotification();
|
||||
|
||||
// Act
|
||||
var result = await connector.SendAsync(notification, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.ShouldRetry.Should().BeTrue("timeout is a transient failure");
|
||||
result.ErrorCode.Should().Be("SMTP_TIMEOUT");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that SMTP authentication failure does NOT trigger retry.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SmtpAuthenticationFailure_DoesNotRetry()
|
||||
{
|
||||
// Arrange
|
||||
var smtpClient = new FailingSmtpClient(
|
||||
new SmtpException(SmtpStatusCode.MustIssueStartTlsFirst, "Authentication required"));
|
||||
var connector = new EmailConnector(smtpClient, new EmailConnectorOptions());
|
||||
var notification = CreateTestNotification();
|
||||
|
||||
// Act
|
||||
var result = await connector.SendAsync(notification, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.ShouldRetry.Should().BeFalse("auth failure is permanent until config is fixed");
|
||||
result.ErrorCode.Should().Be("SMTP_AUTH_FAILURE");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that mail server busy (4xx) triggers retry with backoff.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MailServerBusy_TriggersRetryWithBackoff()
|
||||
{
|
||||
// Arrange
|
||||
var smtpClient = new FailingSmtpClient(
|
||||
new SmtpException(SmtpStatusCode.ServiceClosingTransmissionChannel, "Service temporarily unavailable"));
|
||||
var connector = new EmailConnector(smtpClient, new EmailConnectorOptions
|
||||
{
|
||||
BaseRetryDelayMs = 1000
|
||||
});
|
||||
var notification = CreateTestNotification();
|
||||
|
||||
// Act
|
||||
var result = await connector.SendAsync(notification, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.ShouldRetry.Should().BeTrue();
|
||||
result.RetryAfterMs.Should().BeGreaterOrEqualTo(1000);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Invalid Recipient Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that invalid recipient address fails gracefully without retry.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InvalidRecipient_FailsGracefully()
|
||||
{
|
||||
// Arrange
|
||||
var smtpClient = new FailingSmtpClient(
|
||||
new SmtpFailedRecipientException(SmtpStatusCode.MailboxUnavailable, "not-an-email"));
|
||||
var connector = new EmailConnector(smtpClient, new EmailConnectorOptions());
|
||||
var notification = CreateTestNotification(recipientEmail: "not-an-email");
|
||||
|
||||
// Act
|
||||
var result = await connector.SendAsync(notification, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.ShouldRetry.Should().BeFalse("invalid recipient is a permanent failure");
|
||||
result.ErrorCode.Should().Be("INVALID_RECIPIENT");
|
||||
result.ErrorMessage.Should().Contain("not-an-email");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that mailbox not found fails gracefully without retry.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MailboxNotFound_FailsGracefully()
|
||||
{
|
||||
// Arrange
|
||||
var smtpClient = new FailingSmtpClient(
|
||||
new SmtpFailedRecipientException(SmtpStatusCode.MailboxUnavailable, "unknown@domain.com"));
|
||||
var connector = new EmailConnector(smtpClient, new EmailConnectorOptions());
|
||||
var notification = CreateTestNotification(recipientEmail: "unknown@domain.com");
|
||||
|
||||
// Act
|
||||
var result = await connector.SendAsync(notification, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.ShouldRetry.Should().BeFalse("mailbox not found is permanent");
|
||||
result.ErrorCode.Should().Be("MAILBOX_NOT_FOUND");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that mailbox full triggers retry (could be temporary).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MailboxFull_TriggersRetry()
|
||||
{
|
||||
// Arrange
|
||||
var smtpClient = new FailingSmtpClient(
|
||||
new SmtpFailedRecipientException(SmtpStatusCode.ExceededStorageAllocation, "user@domain.com"));
|
||||
var connector = new EmailConnector(smtpClient, new EmailConnectorOptions());
|
||||
var notification = CreateTestNotification(recipientEmail: "user@domain.com");
|
||||
|
||||
// Act
|
||||
var result = await connector.SendAsync(notification, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.ShouldRetry.Should().BeTrue("mailbox full could be temporary");
|
||||
result.ErrorCode.Should().Be("MAILBOX_FULL");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that multiple invalid recipients reports all failures.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MultipleInvalidRecipients_ReportsAllFailures()
|
||||
{
|
||||
// Arrange
|
||||
var failedRecipients = new[]
|
||||
{
|
||||
new SmtpFailedRecipientException(SmtpStatusCode.MailboxUnavailable, "bad1@domain.com"),
|
||||
new SmtpFailedRecipientException(SmtpStatusCode.MailboxUnavailable, "bad2@domain.com")
|
||||
};
|
||||
var smtpClient = new FailingSmtpClient(
|
||||
new SmtpFailedRecipientsException("Multiple recipients failed", failedRecipients));
|
||||
var connector = new EmailConnector(smtpClient, new EmailConnectorOptions());
|
||||
var notification = CreateTestNotification();
|
||||
|
||||
// Act
|
||||
var result = await connector.SendAsync(notification, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.ShouldRetry.Should().BeFalse();
|
||||
result.FailedRecipients.Should().HaveCount(2);
|
||||
result.FailedRecipients.Should().Contain("bad1@domain.com");
|
||||
result.FailedRecipients.Should().Contain("bad2@domain.com");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that empty recipient list fails validation before sending.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EmptyRecipientList_FailsValidation()
|
||||
{
|
||||
// Arrange
|
||||
var smtpClient = new SucceedingSmtpClient();
|
||||
var connector = new EmailConnector(smtpClient, new EmailConnectorOptions());
|
||||
var notification = new EmailNotification
|
||||
{
|
||||
NotificationId = "notif-001",
|
||||
Subject = "Test",
|
||||
Body = "Test body",
|
||||
Recipients = new List<string>() // Empty
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await connector.SendAsync(notification, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.ShouldRetry.Should().BeFalse();
|
||||
result.ErrorCode.Should().Be("VALIDATION_FAILED");
|
||||
result.ErrorMessage.Should().Contain("recipient");
|
||||
smtpClient.SendAttempts.Should().Be(0, "should fail validation before SMTP");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that empty subject fails validation before sending.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EmptySubject_FailsValidation()
|
||||
{
|
||||
// Arrange
|
||||
var smtpClient = new SucceedingSmtpClient();
|
||||
var connector = new EmailConnector(smtpClient, new EmailConnectorOptions());
|
||||
var notification = new EmailNotification
|
||||
{
|
||||
NotificationId = "notif-001",
|
||||
Subject = "", // Empty
|
||||
Body = "Test body",
|
||||
Recipients = new List<string> { "user@example.com" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await connector.SendAsync(notification, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.ShouldRetry.Should().BeFalse();
|
||||
result.ErrorCode.Should().Be("VALIDATION_FAILED");
|
||||
result.ErrorMessage.Should().Contain("subject");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that malformed email address fails validation.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("not-an-email")]
|
||||
[InlineData("@missing-local.com")]
|
||||
[InlineData("missing-domain@")]
|
||||
[InlineData("spaces in email@domain.com")]
|
||||
[InlineData("<script>alert('xss')</script>@domain.com")]
|
||||
public async Task MalformedEmailAddress_FailsValidation(string badEmail)
|
||||
{
|
||||
// Arrange
|
||||
var smtpClient = new SucceedingSmtpClient();
|
||||
var connector = new EmailConnector(smtpClient, new EmailConnectorOptions());
|
||||
var notification = CreateTestNotification(recipientEmail: badEmail);
|
||||
|
||||
// Act
|
||||
var result = await connector.SendAsync(notification, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.ShouldRetry.Should().BeFalse();
|
||||
result.ErrorCode.Should().Be("VALIDATION_FAILED");
|
||||
smtpClient.SendAttempts.Should().Be(0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Rate Limiting Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that rate limiting error triggers retry with appropriate delay.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RateLimited_TriggersRetryWithDelay()
|
||||
{
|
||||
// Arrange
|
||||
var smtpClient = new FailingSmtpClient(
|
||||
new SmtpException(SmtpStatusCode.InsufficientStorage, "Rate limit exceeded, retry after 60 seconds"));
|
||||
var connector = new EmailConnector(smtpClient, new EmailConnectorOptions());
|
||||
var notification = CreateTestNotification();
|
||||
|
||||
// Act
|
||||
var result = await connector.SendAsync(notification, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.ShouldRetry.Should().BeTrue();
|
||||
result.ErrorCode.Should().Be("RATE_LIMITED");
|
||||
result.RetryAfterMs.Should().BeGreaterOrEqualTo(60000, "should respect retry-after from server");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cancellation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that cancellation is respected.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Cancellation_StopsSend()
|
||||
{
|
||||
// Arrange
|
||||
var smtpClient = new SlowSmtpClient(TimeSpan.FromSeconds(10));
|
||||
var connector = new EmailConnector(smtpClient, new EmailConnectorOptions());
|
||||
var notification = CreateTestNotification();
|
||||
var cts = new CancellationTokenSource();
|
||||
cts.CancelAfter(TimeSpan.FromMilliseconds(100));
|
||||
|
||||
// Act
|
||||
var result = await connector.SendAsync(notification, cts.Token);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.ShouldRetry.Should().BeTrue("cancellation should allow retry");
|
||||
result.ErrorCode.Should().Be("CANCELLED");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Result Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that error results include timestamp.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ErrorResult_IncludesTimestamp()
|
||||
{
|
||||
// Arrange
|
||||
var smtpClient = new FailingSmtpClient(new SmtpException("Test error"));
|
||||
var connector = new EmailConnector(smtpClient, new EmailConnectorOptions());
|
||||
var notification = CreateTestNotification();
|
||||
var before = DateTime.UtcNow;
|
||||
|
||||
// Act
|
||||
var result = await connector.SendAsync(notification, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Timestamp.Should().BeOnOrAfter(before);
|
||||
result.Timestamp.Should().BeOnOrBefore(DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that error results include notification ID.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ErrorResult_IncludesNotificationId()
|
||||
{
|
||||
// Arrange
|
||||
var smtpClient = new FailingSmtpClient(new SmtpException("Test error"));
|
||||
var connector = new EmailConnector(smtpClient, new EmailConnectorOptions());
|
||||
var notification = CreateTestNotification();
|
||||
|
||||
// Act
|
||||
var result = await connector.SendAsync(notification, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.NotificationId.Should().Be(notification.NotificationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that retry count is tracked.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ErrorResult_TracksAttemptCount()
|
||||
{
|
||||
// Arrange
|
||||
var smtpClient = new FailingSmtpClient(new SmtpException("Test error"));
|
||||
var connector = new EmailConnector(smtpClient, new EmailConnectorOptions());
|
||||
var notification = CreateTestNotification();
|
||||
|
||||
// Act - First attempt
|
||||
var result1 = await connector.SendAsync(notification, CancellationToken.None, attempt: 1);
|
||||
var result2 = await connector.SendAsync(notification, CancellationToken.None, attempt: 2);
|
||||
|
||||
// Assert
|
||||
result1.AttemptNumber.Should().Be(1);
|
||||
result2.AttemptNumber.Should().Be(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static EmailNotification CreateTestNotification(string? recipientEmail = null)
|
||||
{
|
||||
return new EmailNotification
|
||||
{
|
||||
NotificationId = $"notif-{Guid.NewGuid():N}",
|
||||
Subject = "[StellaOps] Test Notification",
|
||||
Body = "<html><body>Test notification body</body></html>",
|
||||
Recipients = new List<string> { recipientEmail ?? "user@example.com" },
|
||||
From = "StellaOps <noreply@stellaops.local>",
|
||||
Priority = EmailPriority.Normal
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Test Doubles
|
||||
|
||||
/// <summary>
|
||||
/// Fake SMTP client that always fails with configured exception.
|
||||
/// </summary>
|
||||
internal sealed class FailingSmtpClient : ISmtpClient
|
||||
{
|
||||
private readonly Exception _exception;
|
||||
public int SendAttempts { get; private set; }
|
||||
|
||||
public FailingSmtpClient(Exception exception)
|
||||
{
|
||||
_exception = exception;
|
||||
}
|
||||
|
||||
public Task SendAsync(EmailNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
SendAttempts++;
|
||||
throw _exception;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake SMTP client that always succeeds.
|
||||
/// </summary>
|
||||
internal sealed class SucceedingSmtpClient : ISmtpClient
|
||||
{
|
||||
public int SendAttempts { get; private set; }
|
||||
|
||||
public Task SendAsync(EmailNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
SendAttempts++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake SMTP client that is slow (for cancellation tests).
|
||||
/// </summary>
|
||||
internal sealed class SlowSmtpClient : ISmtpClient
|
||||
{
|
||||
private readonly TimeSpan _delay;
|
||||
|
||||
public SlowSmtpClient(TimeSpan delay)
|
||||
{
|
||||
_delay = delay;
|
||||
}
|
||||
|
||||
public async Task SendAsync(EmailNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Delay(_delay, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SMTP client interface for testing.
|
||||
/// </summary>
|
||||
internal interface ISmtpClient
|
||||
{
|
||||
Task SendAsync(EmailNotification notification, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Email notification model.
|
||||
/// </summary>
|
||||
internal sealed class EmailNotification
|
||||
{
|
||||
public required string NotificationId { get; set; }
|
||||
public required string Subject { get; set; }
|
||||
public required string Body { get; set; }
|
||||
public List<string> Recipients { get; set; } = new();
|
||||
public string? From { get; set; }
|
||||
public EmailPriority Priority { get; set; } = EmailPriority.Normal;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Email priority levels.
|
||||
/// </summary>
|
||||
internal enum EmailPriority
|
||||
{
|
||||
Low,
|
||||
Normal,
|
||||
High
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Email connector options.
|
||||
/// </summary>
|
||||
internal sealed class EmailConnectorOptions
|
||||
{
|
||||
public int MaxRetries { get; set; } = 3;
|
||||
public int RetryDelayMs { get; set; } = 1000;
|
||||
public int BaseRetryDelayMs { get; set; } = 1000;
|
||||
public int TimeoutMs { get; set; } = 30000;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Email send result.
|
||||
/// </summary>
|
||||
internal sealed class EmailSendResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public bool ShouldRetry { get; set; }
|
||||
public int RetryAfterMs { get; set; }
|
||||
public string? ErrorCode { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public List<string> FailedRecipients { get; set; } = new();
|
||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||
public string? NotificationId { get; set; }
|
||||
public int AttemptNumber { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Email connector for testing.
|
||||
/// </summary>
|
||||
internal sealed class EmailConnector
|
||||
{
|
||||
private readonly ISmtpClient _smtpClient;
|
||||
private readonly EmailConnectorOptions _options;
|
||||
|
||||
public EmailConnector(ISmtpClient smtpClient, EmailConnectorOptions options)
|
||||
{
|
||||
_smtpClient = smtpClient;
|
||||
_options = options;
|
||||
}
|
||||
|
||||
public async Task<EmailSendResult> SendAsync(
|
||||
EmailNotification notification,
|
||||
CancellationToken cancellationToken,
|
||||
int attempt = 1)
|
||||
{
|
||||
var result = new EmailSendResult
|
||||
{
|
||||
NotificationId = notification.NotificationId,
|
||||
AttemptNumber = attempt,
|
||||
Timestamp = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// Validate first
|
||||
var validationError = Validate(notification);
|
||||
if (validationError != null)
|
||||
{
|
||||
result.Success = false;
|
||||
result.ShouldRetry = false;
|
||||
result.ErrorCode = "VALIDATION_FAILED";
|
||||
result.ErrorMessage = validationError;
|
||||
return result;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _smtpClient.SendAsync(notification, cancellationToken);
|
||||
result.Success = true;
|
||||
return result;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
result.Success = false;
|
||||
result.ShouldRetry = true;
|
||||
result.ErrorCode = "CANCELLED";
|
||||
return result;
|
||||
}
|
||||
catch (SmtpFailedRecipientsException ex)
|
||||
{
|
||||
result.Success = false;
|
||||
result.ShouldRetry = false;
|
||||
result.ErrorCode = "MULTIPLE_RECIPIENTS_FAILED";
|
||||
result.FailedRecipients = ex.InnerExceptions
|
||||
.OfType<SmtpFailedRecipientException>()
|
||||
.Select(e => e.FailedRecipient)
|
||||
.ToList();
|
||||
return result;
|
||||
}
|
||||
catch (SmtpFailedRecipientException ex)
|
||||
{
|
||||
result.Success = false;
|
||||
result.ErrorMessage = ex.FailedRecipient;
|
||||
|
||||
// Classify recipient failure
|
||||
(result.ErrorCode, result.ShouldRetry) = ex.StatusCode switch
|
||||
{
|
||||
SmtpStatusCode.MailboxUnavailable when ex.FailedRecipient.Contains('@') == false
|
||||
=> ("INVALID_RECIPIENT", false),
|
||||
SmtpStatusCode.MailboxUnavailable => ("MAILBOX_NOT_FOUND", false),
|
||||
SmtpStatusCode.ExceededStorageAllocation => ("MAILBOX_FULL", true),
|
||||
_ => ("RECIPIENT_FAILED", false)
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (SmtpException ex)
|
||||
{
|
||||
result.Success = false;
|
||||
result.ErrorMessage = ex.Message;
|
||||
|
||||
// Classify SMTP failure
|
||||
(result.ErrorCode, result.ShouldRetry, result.RetryAfterMs) = ClassifySmtpException(ex);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Success = false;
|
||||
result.ShouldRetry = true;
|
||||
result.ErrorCode = "UNKNOWN_ERROR";
|
||||
result.ErrorMessage = ex.Message;
|
||||
result.RetryAfterMs = _options.RetryDelayMs;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? Validate(EmailNotification notification)
|
||||
{
|
||||
if (notification.Recipients == null || notification.Recipients.Count == 0)
|
||||
return "At least one recipient is required";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(notification.Subject))
|
||||
return "subject is required";
|
||||
|
||||
foreach (var recipient in notification.Recipients)
|
||||
{
|
||||
if (!IsValidEmail(recipient))
|
||||
return $"Invalid email address: {recipient}";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsValidEmail(string email)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
return false;
|
||||
|
||||
if (email.Contains(' '))
|
||||
return false;
|
||||
|
||||
if (email.Contains('<') || email.Contains('>'))
|
||||
return false;
|
||||
|
||||
var atIndex = email.IndexOf('@');
|
||||
if (atIndex <= 0 || atIndex >= email.Length - 1)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private (string ErrorCode, bool ShouldRetry, int RetryAfterMs) ClassifySmtpException(SmtpException ex)
|
||||
{
|
||||
// Check for rate limiting
|
||||
if (ex.Message.Contains("Rate limit", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Extract retry-after if present
|
||||
var retryAfter = 60000; // Default 60 seconds
|
||||
if (ex.Message.Contains("retry after", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Parse retry-after from message if present
|
||||
var match = System.Text.RegularExpressions.Regex.Match(ex.Message, @"(\d+)\s*seconds");
|
||||
if (match.Success)
|
||||
retryAfter = int.Parse(match.Groups[1].Value) * 1000;
|
||||
}
|
||||
return ("RATE_LIMITED", true, retryAfter);
|
||||
}
|
||||
|
||||
// Classify by status code
|
||||
return ex.StatusCode switch
|
||||
{
|
||||
SmtpStatusCode.ServiceNotAvailable => ("SMTP_UNAVAILABLE", true, _options.RetryDelayMs),
|
||||
SmtpStatusCode.ServiceClosingTransmissionChannel => ("SMTP_CLOSING", true, _options.BaseRetryDelayMs),
|
||||
SmtpStatusCode.MustIssueStartTlsFirst => ("SMTP_AUTH_FAILURE", false, 0),
|
||||
SmtpStatusCode.GeneralFailure when ex.Message.Contains("timeout", StringComparison.OrdinalIgnoreCase)
|
||||
=> ("SMTP_TIMEOUT", true, _options.RetryDelayMs),
|
||||
SmtpStatusCode.InsufficientStorage => ("RATE_LIMITED", true, _options.RetryDelayMs),
|
||||
_ => ("SMTP_ERROR", true, _options.RetryDelayMs)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,56 @@
|
||||
Subject: [StellaOps] Policy Violation - No Root Containers - acme/legacy-app:v0.9.0
|
||||
From: StellaOps <noreply@stellaops.local>
|
||||
To: ACME DevOps Team <devops@acme.example.com>
|
||||
Reply-To: noreply@stellaops.local
|
||||
X-Priority: 1
|
||||
Content-Type: text/html; charset=utf-8
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Policy Violation</title>
|
||||
</head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<div style="background: #f59e0b; color: white; padding: 20px; border-radius: 8px 8px 0 0;">
|
||||
<h1 style="margin: 0; font-size: 24px;">🚨 Policy Violation Detected</h1>
|
||||
</div>
|
||||
|
||||
<div style="border: 1px solid #e5e7eb; border-top: none; padding: 20px; border-radius: 0 0 8px 8px;">
|
||||
<h2 style="margin-top: 0;">Policy: No Root Containers</h2>
|
||||
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;"><strong>Image:</strong></td>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;">acme/legacy-app:v0.9.0</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;"><strong>Digest:</strong></td>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;"><code>sha256:def456ghi789</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;"><strong>Violation Type:</strong></td>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;">container_runs_as_root</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0;"><strong>Detected At:</strong></td>
|
||||
<td style="padding: 8px 0;">2026-01-16T09:15:00Z</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3>Details</h3>
|
||||
<p style="background: #fef3c7; padding: 16px; border-radius: 8px; color: #92400e;">
|
||||
Container is configured to run as root user (UID 0). This violates the organization's security policy requiring non-root containers.
|
||||
</p>
|
||||
|
||||
<h3>Remediation</h3>
|
||||
<p style="background: #d1fae5; padding: 16px; border-radius: 8px; color: #065f46;">
|
||||
Update the Dockerfile to use a non-root user: <code>USER 1000:1000</code>
|
||||
</p>
|
||||
|
||||
<p style="margin-top: 20px; color: #6b7280; font-size: 14px;">
|
||||
This is an automated message from StellaOps. Do not reply to this email.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,77 @@
|
||||
Subject: [StellaOps] ⚠️ CRITICAL - Scan Failed - acme/api-server:v2.0.0
|
||||
From: StellaOps <noreply@stellaops.local>
|
||||
To: ACME Security Team <security@acme.example.com>
|
||||
Reply-To: noreply@stellaops.local
|
||||
X-Priority: 1
|
||||
Content-Type: text/html; charset=utf-8
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Scan Results - CRITICAL</title>
|
||||
</head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<div style="background: #dc2626; color: white; padding: 20px; border-radius: 8px 8px 0 0;">
|
||||
<h1 style="margin: 0; font-size: 24px;">⚠️ Scan Failed - Critical Vulnerabilities Found</h1>
|
||||
</div>
|
||||
|
||||
<div style="border: 1px solid #e5e7eb; border-top: none; padding: 20px; border-radius: 0 0 8px 8px;">
|
||||
<h2 style="margin-top: 0;">Image Details</h2>
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;"><strong>Image:</strong></td>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;">acme/api-server:v2.0.0</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;"><strong>Digest:</strong></td>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;"><code>sha256:xyz789abc123</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;"><strong>Scan ID:</strong></td>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;">scan-xyz789</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0;"><strong>Scanned At:</strong></td>
|
||||
<td style="padding: 8px 0;">2026-01-15T14:45:00Z</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2>Vulnerability Summary</h2>
|
||||
<table style="width: 100%; border-collapse: collapse; text-align: center;">
|
||||
<tr style="background: #f3f4f6;">
|
||||
<th style="padding: 12px;">Critical</th>
|
||||
<th style="padding: 12px;">High</th>
|
||||
<th style="padding: 12px;">Medium</th>
|
||||
<th style="padding: 12px;">Low</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; font-size: 24px; font-weight: bold; color: #7f1d1d; background: #fecaca;">2</td>
|
||||
<td style="padding: 12px; font-size: 24px; font-weight: bold; color: #9a3412; background: #fed7aa;">3</td>
|
||||
<td style="padding: 12px; font-size: 24px; font-weight: bold; color: #a16207;">1</td>
|
||||
<td style="padding: 12px; font-size: 24px; font-weight: bold; color: #166534;">2</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2 style="color: #dc2626;">Critical Vulnerabilities</h2>
|
||||
<div style="background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px; padding: 16px; margin-bottom: 12px;">
|
||||
<h3 style="margin: 0 0 8px 0; color: #991b1b;">CVE-2026-1234 (CVSS 9.8)</h3>
|
||||
<p style="margin: 0 0 8px 0;"><strong>Package:</strong> openssl</p>
|
||||
<p style="margin: 0;">Remote Code Execution in OpenSSL</p>
|
||||
</div>
|
||||
<div style="background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px; padding: 16px; margin-bottom: 12px;">
|
||||
<h3 style="margin: 0 0 8px 0; color: #991b1b;">CVE-2026-5678 (CVSS 9.1)</h3>
|
||||
<p style="margin: 0 0 8px 0;"><strong>Package:</strong> libcurl</p>
|
||||
<p style="margin: 0;">Buffer Overflow in libcurl</p>
|
||||
</div>
|
||||
|
||||
<p style="margin-top: 20px; padding: 16px; background: #fef3c7; border-radius: 8px; color: #92400e;">
|
||||
<strong>Action Required:</strong> This image should not be deployed to production until the critical vulnerabilities are remediated.
|
||||
</p>
|
||||
|
||||
<p style="margin-top: 20px; color: #6b7280; font-size: 14px;">
|
||||
This is an automated message from StellaOps. Do not reply to this email.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,60 @@
|
||||
Subject: [StellaOps] Scan Completed - PASS - acme/webapp:v1.2.3
|
||||
From: StellaOps <noreply@stellaops.local>
|
||||
To: ACME Security Team <security@acme.example.com>
|
||||
Reply-To: noreply@stellaops.local
|
||||
Content-Type: text/html; charset=utf-8
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Scan Results</title>
|
||||
</head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<div style="background: #10b981; color: white; padding: 20px; border-radius: 8px 8px 0 0;">
|
||||
<h1 style="margin: 0; font-size: 24px;">✓ Scan Passed</h1>
|
||||
</div>
|
||||
|
||||
<div style="border: 1px solid #e5e7eb; border-top: none; padding: 20px; border-radius: 0 0 8px 8px;">
|
||||
<h2 style="margin-top: 0;">Image Details</h2>
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;"><strong>Image:</strong></td>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;">acme/webapp:v1.2.3</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;"><strong>Digest:</strong></td>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;"><code>sha256:abc123def456</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;"><strong>Scan ID:</strong></td>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #e5e7eb;">scan-abc123</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0;"><strong>Scanned At:</strong></td>
|
||||
<td style="padding: 8px 0;">2026-01-15T10:30:00Z</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2>Vulnerability Summary</h2>
|
||||
<table style="width: 100%; border-collapse: collapse; text-align: center;">
|
||||
<tr style="background: #f3f4f6;">
|
||||
<th style="padding: 12px;">Critical</th>
|
||||
<th style="padding: 12px;">High</th>
|
||||
<th style="padding: 12px;">Medium</th>
|
||||
<th style="padding: 12px;">Low</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px; font-size: 24px; font-weight: bold; color: #7f1d1d;">0</td>
|
||||
<td style="padding: 12px; font-size: 24px; font-weight: bold; color: #9a3412;">0</td>
|
||||
<td style="padding: 12px; font-size: 24px; font-weight: bold; color: #a16207;">2</td>
|
||||
<td style="padding: 12px; font-size: 24px; font-weight: bold; color: #166534;">5</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin-top: 20px; color: #6b7280; font-size: 14px;">
|
||||
This is an automated message from StellaOps. Do not reply to this email.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"notification_id": "notif-003",
|
||||
"tenant_id": "tenant-acme",
|
||||
"channel": "email",
|
||||
"event_type": "policy.violation",
|
||||
"timestamp": "2026-01-16T09:15:00Z",
|
||||
"payload": {
|
||||
"policy_id": "policy-no-root",
|
||||
"policy_name": "No Root Containers",
|
||||
"image_digest": "sha256:def456ghi789",
|
||||
"image_name": "acme/legacy-app:v0.9.0",
|
||||
"violation_type": "container_runs_as_root",
|
||||
"details": "Container is configured to run as root user (UID 0). This violates the organization's security policy requiring non-root containers.",
|
||||
"remediation": "Update the Dockerfile to use a non-root user: USER 1000:1000"
|
||||
},
|
||||
"recipient": {
|
||||
"email": "devops@acme.example.com",
|
||||
"name": "ACME DevOps Team"
|
||||
},
|
||||
"metadata": {
|
||||
"priority": "high",
|
||||
"reply_to": "noreply@stellaops.local"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"notification_id": "notif-002",
|
||||
"tenant_id": "tenant-acme",
|
||||
"channel": "email",
|
||||
"event_type": "scan.completed",
|
||||
"timestamp": "2026-01-15T14:45:00Z",
|
||||
"payload": {
|
||||
"scan_id": "scan-xyz789",
|
||||
"image_digest": "sha256:xyz789abc123",
|
||||
"image_name": "acme/api-server:v2.0.0",
|
||||
"verdict": "fail",
|
||||
"findings_count": 8,
|
||||
"vulnerabilities": {
|
||||
"critical": 2,
|
||||
"high": 3,
|
||||
"medium": 1,
|
||||
"low": 2
|
||||
},
|
||||
"critical_findings": [
|
||||
{
|
||||
"cve_id": "CVE-2026-1234",
|
||||
"package": "openssl",
|
||||
"severity": "critical",
|
||||
"title": "Remote Code Execution in OpenSSL",
|
||||
"cvss": 9.8
|
||||
},
|
||||
{
|
||||
"cve_id": "CVE-2026-5678",
|
||||
"package": "libcurl",
|
||||
"severity": "critical",
|
||||
"title": "Buffer Overflow in libcurl",
|
||||
"cvss": 9.1
|
||||
}
|
||||
]
|
||||
},
|
||||
"recipient": {
|
||||
"email": "security@acme.example.com",
|
||||
"name": "ACME Security Team"
|
||||
},
|
||||
"metadata": {
|
||||
"priority": "high",
|
||||
"reply_to": "noreply@stellaops.local"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"notification_id": "notif-001",
|
||||
"tenant_id": "tenant-acme",
|
||||
"channel": "email",
|
||||
"event_type": "scan.completed",
|
||||
"timestamp": "2026-01-15T10:30:00Z",
|
||||
"payload": {
|
||||
"scan_id": "scan-abc123",
|
||||
"image_digest": "sha256:abc123def456",
|
||||
"image_name": "acme/webapp:v1.2.3",
|
||||
"verdict": "pass",
|
||||
"findings_count": 0,
|
||||
"vulnerabilities": {
|
||||
"critical": 0,
|
||||
"high": 0,
|
||||
"medium": 2,
|
||||
"low": 5
|
||||
}
|
||||
},
|
||||
"recipient": {
|
||||
"email": "security@acme.example.com",
|
||||
"name": "ACME Security Team"
|
||||
},
|
||||
"metadata": {
|
||||
"priority": "normal",
|
||||
"reply_to": "noreply@stellaops.local"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
// ---------------------------------------------------------------------
|
||||
// <copyright file="EmailConnectorSnapshotTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
// <summary>
|
||||
// Payload formatting snapshot tests for email connector: event → formatted email → assert snapshot
|
||||
// </summary>
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Email.Tests.Snapshot;
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot tests for email connector payload formatting.
|
||||
/// Verifies event → formatted email output matches expected snapshots.
|
||||
/// </summary>
|
||||
[Trait("Category", "Snapshot")]
|
||||
[Trait("Sprint", "5100-0009-0009")]
|
||||
public sealed class EmailConnectorSnapshotTests
|
||||
{
|
||||
private readonly string _fixturesPath;
|
||||
private readonly string _expectedPath;
|
||||
private readonly EmailFormatter _formatter;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public EmailConnectorSnapshotTests()
|
||||
{
|
||||
var assemblyDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
|
||||
_fixturesPath = Path.Combine(assemblyDir, "Fixtures", "email");
|
||||
_expectedPath = Path.Combine(assemblyDir, "Expected");
|
||||
_formatter = new EmailFormatter();
|
||||
}
|
||||
|
||||
#region Scan Completed Pass Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies scan completed (pass) event formats to expected email.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ScanCompletedPass_FormatsToExpectedEmail()
|
||||
{
|
||||
// Arrange
|
||||
var eventJson = await LoadFixtureAsync("scan_completed_pass.json");
|
||||
var expected = await LoadExpectedAsync("scan_completed_pass.email.txt");
|
||||
var notificationEvent = JsonSerializer.Deserialize<NotificationEvent>(eventJson, JsonOptions)!;
|
||||
|
||||
// Act
|
||||
var formattedEmail = _formatter.Format(notificationEvent);
|
||||
|
||||
// Assert
|
||||
formattedEmail.Subject.Should().Be("[StellaOps] Scan Completed - PASS - acme/webapp:v1.2.3");
|
||||
formattedEmail.From.Should().Be("StellaOps <noreply@stellaops.local>");
|
||||
formattedEmail.To.Should().Be("ACME Security Team <security@acme.example.com>");
|
||||
formattedEmail.Body.Should().Contain("✓ Scan Passed");
|
||||
formattedEmail.Body.Should().Contain("acme/webapp:v1.2.3");
|
||||
formattedEmail.Body.Should().Contain("sha256:abc123def456");
|
||||
|
||||
// Verify snapshot structure matches
|
||||
AssertEmailSnapshotMatch(formattedEmail, expected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies scan completed (pass) includes correct vulnerability counts.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ScanCompletedPass_IncludesVulnerabilityCounts()
|
||||
{
|
||||
// Arrange
|
||||
var eventJson = await LoadFixtureAsync("scan_completed_pass.json");
|
||||
var notificationEvent = JsonSerializer.Deserialize<NotificationEvent>(eventJson, JsonOptions)!;
|
||||
|
||||
// Act
|
||||
var formattedEmail = _formatter.Format(notificationEvent);
|
||||
|
||||
// Assert
|
||||
formattedEmail.Body.Should().Contain(">0</td>"); // Critical count
|
||||
formattedEmail.Body.Should().Contain(">2</td>"); // Medium count
|
||||
formattedEmail.Body.Should().Contain(">5</td>"); // Low count
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Scan Completed Fail Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies scan completed (fail) event formats to expected email.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ScanCompletedFail_FormatsToExpectedEmail()
|
||||
{
|
||||
// Arrange
|
||||
var eventJson = await LoadFixtureAsync("scan_completed_fail.json");
|
||||
var expected = await LoadExpectedAsync("scan_completed_fail.email.txt");
|
||||
var notificationEvent = JsonSerializer.Deserialize<NotificationEvent>(eventJson, JsonOptions)!;
|
||||
|
||||
// Act
|
||||
var formattedEmail = _formatter.Format(notificationEvent);
|
||||
|
||||
// Assert
|
||||
formattedEmail.Subject.Should().Contain("CRITICAL");
|
||||
formattedEmail.Subject.Should().Contain("Scan Failed");
|
||||
formattedEmail.Subject.Should().Contain("acme/api-server:v2.0.0");
|
||||
formattedEmail.Priority.Should().Be(EmailPriority.High);
|
||||
formattedEmail.Body.Should().Contain("Critical Vulnerabilities Found");
|
||||
|
||||
AssertEmailSnapshotMatch(formattedEmail, expected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies scan completed (fail) lists critical findings.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ScanCompletedFail_ListsCriticalFindings()
|
||||
{
|
||||
// Arrange
|
||||
var eventJson = await LoadFixtureAsync("scan_completed_fail.json");
|
||||
var notificationEvent = JsonSerializer.Deserialize<NotificationEvent>(eventJson, JsonOptions)!;
|
||||
|
||||
// Act
|
||||
var formattedEmail = _formatter.Format(notificationEvent);
|
||||
|
||||
// Assert
|
||||
formattedEmail.Body.Should().Contain("CVE-2026-1234");
|
||||
formattedEmail.Body.Should().Contain("CVE-2026-5678");
|
||||
formattedEmail.Body.Should().Contain("openssl");
|
||||
formattedEmail.Body.Should().Contain("libcurl");
|
||||
formattedEmail.Body.Should().Contain("CVSS 9.8");
|
||||
formattedEmail.Body.Should().Contain("CVSS 9.1");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies scan completed (fail) includes action required warning.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ScanCompletedFail_IncludesActionRequired()
|
||||
{
|
||||
// Arrange
|
||||
var eventJson = await LoadFixtureAsync("scan_completed_fail.json");
|
||||
var notificationEvent = JsonSerializer.Deserialize<NotificationEvent>(eventJson, JsonOptions)!;
|
||||
|
||||
// Act
|
||||
var formattedEmail = _formatter.Format(notificationEvent);
|
||||
|
||||
// Assert
|
||||
formattedEmail.Body.Should().Contain("Action Required");
|
||||
formattedEmail.Body.Should().Contain("should not be deployed to production");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Policy Violation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies policy violation event formats to expected email.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task PolicyViolation_FormatsToExpectedEmail()
|
||||
{
|
||||
// Arrange
|
||||
var eventJson = await LoadFixtureAsync("policy_violation.json");
|
||||
var expected = await LoadExpectedAsync("policy_violation.email.txt");
|
||||
var notificationEvent = JsonSerializer.Deserialize<NotificationEvent>(eventJson, JsonOptions)!;
|
||||
|
||||
// Act
|
||||
var formattedEmail = _formatter.Format(notificationEvent);
|
||||
|
||||
// Assert
|
||||
formattedEmail.Subject.Should().Contain("Policy Violation");
|
||||
formattedEmail.Subject.Should().Contain("No Root Containers");
|
||||
formattedEmail.Body.Should().Contain("Policy Violation Detected");
|
||||
formattedEmail.Body.Should().Contain("container_runs_as_root");
|
||||
|
||||
AssertEmailSnapshotMatch(formattedEmail, expected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies policy violation includes remediation guidance.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task PolicyViolation_IncludesRemediation()
|
||||
{
|
||||
// Arrange
|
||||
var eventJson = await LoadFixtureAsync("policy_violation.json");
|
||||
var notificationEvent = JsonSerializer.Deserialize<NotificationEvent>(eventJson, JsonOptions)!;
|
||||
|
||||
// Act
|
||||
var formattedEmail = _formatter.Format(notificationEvent);
|
||||
|
||||
// Assert
|
||||
formattedEmail.Body.Should().Contain("Remediation");
|
||||
formattedEmail.Body.Should().Contain("USER 1000:1000");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Header Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies all emails include required headers.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("scan_completed_pass.json")]
|
||||
[InlineData("scan_completed_fail.json")]
|
||||
[InlineData("policy_violation.json")]
|
||||
public async Task AllEmails_IncludeRequiredHeaders(string fixtureFile)
|
||||
{
|
||||
// Arrange
|
||||
var eventJson = await LoadFixtureAsync(fixtureFile);
|
||||
var notificationEvent = JsonSerializer.Deserialize<NotificationEvent>(eventJson, JsonOptions)!;
|
||||
|
||||
// Act
|
||||
var formattedEmail = _formatter.Format(notificationEvent);
|
||||
|
||||
// Assert
|
||||
formattedEmail.Subject.Should().NotBeNullOrWhiteSpace();
|
||||
formattedEmail.From.Should().NotBeNullOrWhiteSpace();
|
||||
formattedEmail.To.Should().NotBeNullOrWhiteSpace();
|
||||
formattedEmail.ContentType.Should().Be("text/html; charset=utf-8");
|
||||
formattedEmail.ReplyTo.Should().NotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies high priority events set email priority header.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("scan_completed_fail.json", EmailPriority.High)]
|
||||
[InlineData("policy_violation.json", EmailPriority.High)]
|
||||
[InlineData("scan_completed_pass.json", EmailPriority.Normal)]
|
||||
public async Task HighPriorityEvents_SetPriorityHeader(string fixtureFile, EmailPriority expectedPriority)
|
||||
{
|
||||
// Arrange
|
||||
var eventJson = await LoadFixtureAsync(fixtureFile);
|
||||
var notificationEvent = JsonSerializer.Deserialize<NotificationEvent>(eventJson, JsonOptions)!;
|
||||
|
||||
// Act
|
||||
var formattedEmail = _formatter.Format(notificationEvent);
|
||||
|
||||
// Assert
|
||||
formattedEmail.Priority.Should().Be(expectedPriority);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region HTML Validation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies email body is valid HTML.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("scan_completed_pass.json")]
|
||||
[InlineData("scan_completed_fail.json")]
|
||||
[InlineData("policy_violation.json")]
|
||||
public async Task EmailBody_IsValidHtml(string fixtureFile)
|
||||
{
|
||||
// Arrange
|
||||
var eventJson = await LoadFixtureAsync(fixtureFile);
|
||||
var notificationEvent = JsonSerializer.Deserialize<NotificationEvent>(eventJson, JsonOptions)!;
|
||||
|
||||
// Act
|
||||
var formattedEmail = _formatter.Format(notificationEvent);
|
||||
|
||||
// Assert
|
||||
formattedEmail.Body.Should().Contain("<!DOCTYPE html>");
|
||||
formattedEmail.Body.Should().Contain("<html>");
|
||||
formattedEmail.Body.Should().Contain("</html>");
|
||||
formattedEmail.Body.Should().Contain("<body");
|
||||
formattedEmail.Body.Should().Contain("</body>");
|
||||
|
||||
// Verify no unclosed tags (basic check)
|
||||
var openTags = formattedEmail.Body.Split('<').Length;
|
||||
var closeTags = formattedEmail.Body.Split('>').Length;
|
||||
openTags.Should().Be(closeTags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies email body escapes HTML special characters in user data.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EmailBody_EscapesHtmlSpecialCharacters()
|
||||
{
|
||||
// Arrange
|
||||
var maliciousEvent = new NotificationEvent
|
||||
{
|
||||
NotificationId = "notif-xss",
|
||||
TenantId = "tenant",
|
||||
Channel = "email",
|
||||
EventType = "scan.completed",
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Payload = new Dictionary<string, object>
|
||||
{
|
||||
["image_name"] = "<script>alert('xss')</script>",
|
||||
["scan_id"] = "scan-123",
|
||||
["verdict"] = "pass",
|
||||
["vulnerabilities"] = new Dictionary<string, int>
|
||||
{
|
||||
["critical"] = 0,
|
||||
["high"] = 0,
|
||||
["medium"] = 0,
|
||||
["low"] = 0
|
||||
}
|
||||
},
|
||||
Recipient = new NotificationRecipient
|
||||
{
|
||||
Email = "test@example.com",
|
||||
Name = "Test"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var formattedEmail = _formatter.Format(maliciousEvent);
|
||||
|
||||
// Assert
|
||||
formattedEmail.Body.Should().NotContain("<script>");
|
||||
formattedEmail.Body.Should().Contain("<script>");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies same input produces identical output (deterministic).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("scan_completed_pass.json")]
|
||||
[InlineData("scan_completed_fail.json")]
|
||||
[InlineData("policy_violation.json")]
|
||||
public async Task SameInput_ProducesIdenticalOutput(string fixtureFile)
|
||||
{
|
||||
// Arrange
|
||||
var eventJson = await LoadFixtureAsync(fixtureFile);
|
||||
var notificationEvent = JsonSerializer.Deserialize<NotificationEvent>(eventJson, JsonOptions)!;
|
||||
|
||||
// Act
|
||||
var email1 = _formatter.Format(notificationEvent);
|
||||
var email2 = _formatter.Format(notificationEvent);
|
||||
|
||||
// Assert
|
||||
email1.Subject.Should().Be(email2.Subject);
|
||||
email1.From.Should().Be(email2.From);
|
||||
email1.To.Should().Be(email2.To);
|
||||
email1.Body.Should().Be(email2.Body);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private async Task<string> LoadFixtureAsync(string filename)
|
||||
{
|
||||
var path = Path.Combine(_fixturesPath, filename);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
// Fall back to embedded resource or test data directory
|
||||
var testDataPath = Path.Combine(
|
||||
Directory.GetCurrentDirectory(),
|
||||
"Fixtures", "email", filename);
|
||||
if (File.Exists(testDataPath))
|
||||
{
|
||||
path = testDataPath;
|
||||
}
|
||||
}
|
||||
return await File.ReadAllTextAsync(path);
|
||||
}
|
||||
|
||||
private async Task<string> LoadExpectedAsync(string filename)
|
||||
{
|
||||
var path = Path.Combine(_expectedPath, filename);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
var testDataPath = Path.Combine(
|
||||
Directory.GetCurrentDirectory(),
|
||||
"Expected", filename);
|
||||
if (File.Exists(testDataPath))
|
||||
{
|
||||
path = testDataPath;
|
||||
}
|
||||
}
|
||||
return await File.ReadAllTextAsync(path);
|
||||
}
|
||||
|
||||
private static void AssertEmailSnapshotMatch(FormattedEmail actual, string expectedSnapshot)
|
||||
{
|
||||
// Parse expected snapshot for key elements
|
||||
var lines = expectedSnapshot.Split('\n');
|
||||
foreach (var line in lines.Take(10)) // Check headers
|
||||
{
|
||||
if (line.StartsWith("Subject:"))
|
||||
{
|
||||
var expectedSubject = line["Subject:".Length..].Trim();
|
||||
actual.Subject.Should().Be(expectedSubject);
|
||||
}
|
||||
else if (line.StartsWith("From:"))
|
||||
{
|
||||
var expectedFrom = line["From:".Length..].Trim();
|
||||
actual.From.Should().Be(expectedFrom);
|
||||
}
|
||||
else if (line.StartsWith("To:"))
|
||||
{
|
||||
var expectedTo = line["To:".Length..].Trim();
|
||||
actual.To.Should().Be(expectedTo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Test Models
|
||||
|
||||
/// <summary>
|
||||
/// Notification event model for testing.
|
||||
/// </summary>
|
||||
public sealed class NotificationEvent
|
||||
{
|
||||
public required string NotificationId { get; set; }
|
||||
public required string TenantId { get; set; }
|
||||
public required string Channel { get; set; }
|
||||
public required string EventType { get; set; }
|
||||
public DateTime Timestamp { get; set; }
|
||||
public Dictionary<string, object> Payload { get; set; } = new();
|
||||
public required NotificationRecipient Recipient { get; set; }
|
||||
public Dictionary<string, string> Metadata { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notification recipient model for testing.
|
||||
/// </summary>
|
||||
public sealed class NotificationRecipient
|
||||
{
|
||||
public required string Email { get; set; }
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formatted email model.
|
||||
/// </summary>
|
||||
public sealed class FormattedEmail
|
||||
{
|
||||
public required string Subject { get; set; }
|
||||
public required string From { get; set; }
|
||||
public required string To { get; set; }
|
||||
public string? ReplyTo { get; set; }
|
||||
public required string Body { get; set; }
|
||||
public string ContentType { get; set; } = "text/html; charset=utf-8";
|
||||
public EmailPriority Priority { get; set; } = EmailPriority.Normal;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Email priority levels.
|
||||
/// </summary>
|
||||
public enum EmailPriority
|
||||
{
|
||||
Low,
|
||||
Normal,
|
||||
High
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Email formatter for testing.
|
||||
/// </summary>
|
||||
public sealed class EmailFormatter
|
||||
{
|
||||
public FormattedEmail Format(NotificationEvent evt)
|
||||
{
|
||||
var priority = GetPriority(evt);
|
||||
var subject = FormatSubject(evt);
|
||||
var body = FormatBody(evt);
|
||||
|
||||
var recipientName = evt.Recipient.Name ?? evt.Recipient.Email;
|
||||
var to = !string.IsNullOrEmpty(evt.Recipient.Name)
|
||||
? $"{evt.Recipient.Name} <{evt.Recipient.Email}>"
|
||||
: evt.Recipient.Email;
|
||||
|
||||
var replyTo = evt.Metadata.TryGetValue("reply_to", out var rt) ? rt : "noreply@stellaops.local";
|
||||
|
||||
return new FormattedEmail
|
||||
{
|
||||
Subject = subject,
|
||||
From = "StellaOps <noreply@stellaops.local>",
|
||||
To = to,
|
||||
ReplyTo = replyTo,
|
||||
Body = body,
|
||||
Priority = priority
|
||||
};
|
||||
}
|
||||
|
||||
private static EmailPriority GetPriority(NotificationEvent evt)
|
||||
{
|
||||
if (evt.Metadata.TryGetValue("priority", out var p) && p == "high")
|
||||
return EmailPriority.High;
|
||||
|
||||
if (evt.Payload.TryGetValue("verdict", out var v) && v?.ToString() == "fail")
|
||||
return EmailPriority.High;
|
||||
|
||||
if (evt.EventType == "policy.violation")
|
||||
return EmailPriority.High;
|
||||
|
||||
return EmailPriority.Normal;
|
||||
}
|
||||
|
||||
private static string FormatSubject(NotificationEvent evt)
|
||||
{
|
||||
return evt.EventType switch
|
||||
{
|
||||
"scan.completed" => FormatScanCompletedSubject(evt),
|
||||
"policy.violation" => FormatPolicyViolationSubject(evt),
|
||||
_ => $"[StellaOps] Notification - {evt.EventType}"
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatScanCompletedSubject(NotificationEvent evt)
|
||||
{
|
||||
var verdict = evt.Payload.GetValueOrDefault("verdict")?.ToString()?.ToUpperInvariant() ?? "UNKNOWN";
|
||||
var imageName = evt.Payload.GetValueOrDefault("image_name")?.ToString() ?? "unknown";
|
||||
|
||||
if (verdict == "FAIL")
|
||||
{
|
||||
return $"[StellaOps] ⚠️ CRITICAL - Scan Failed - {imageName}";
|
||||
}
|
||||
|
||||
return $"[StellaOps] Scan Completed - {verdict} - {imageName}";
|
||||
}
|
||||
|
||||
private static string FormatPolicyViolationSubject(NotificationEvent evt)
|
||||
{
|
||||
var policyName = evt.Payload.GetValueOrDefault("policy_name")?.ToString() ?? "Unknown Policy";
|
||||
var imageName = evt.Payload.GetValueOrDefault("image_name")?.ToString() ?? "unknown";
|
||||
return $"[StellaOps] Policy Violation - {policyName} - {imageName}";
|
||||
}
|
||||
|
||||
private static string FormatBody(NotificationEvent evt)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine("<!DOCTYPE html>");
|
||||
sb.AppendLine("<html>");
|
||||
sb.AppendLine("<head>");
|
||||
sb.AppendLine(" <meta charset=\"utf-8\">");
|
||||
sb.AppendLine($" <title>{HtmlEncode(evt.EventType)}</title>");
|
||||
sb.AppendLine("</head>");
|
||||
sb.AppendLine("<body style=\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;\">");
|
||||
|
||||
switch (evt.EventType)
|
||||
{
|
||||
case "scan.completed":
|
||||
FormatScanCompletedBody(sb, evt);
|
||||
break;
|
||||
case "policy.violation":
|
||||
FormatPolicyViolationBody(sb, evt);
|
||||
break;
|
||||
default:
|
||||
sb.AppendLine($" <p>Notification: {HtmlEncode(evt.EventType)}</p>");
|
||||
break;
|
||||
}
|
||||
|
||||
sb.AppendLine("</body>");
|
||||
sb.AppendLine("</html>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static void FormatScanCompletedBody(System.Text.StringBuilder sb, NotificationEvent evt)
|
||||
{
|
||||
var verdict = evt.Payload.GetValueOrDefault("verdict")?.ToString() ?? "unknown";
|
||||
var isPassing = verdict.Equals("pass", StringComparison.OrdinalIgnoreCase);
|
||||
var headerColor = isPassing ? "#10b981" : "#dc2626";
|
||||
var headerText = isPassing ? "✓ Scan Passed" : "⚠️ Scan Failed - Critical Vulnerabilities Found";
|
||||
|
||||
sb.AppendLine($" <div style=\"background: {headerColor}; color: white; padding: 20px; border-radius: 8px 8px 0 0;\">");
|
||||
sb.AppendLine($" <h1 style=\"margin: 0; font-size: 24px;\">{headerText}</h1>");
|
||||
sb.AppendLine(" </div>");
|
||||
|
||||
sb.AppendLine(" <div style=\"border: 1px solid #e5e7eb; border-top: none; padding: 20px; border-radius: 0 0 8px 8px;\">");
|
||||
sb.AppendLine(" <h2 style=\"margin-top: 0;\">Image Details</h2>");
|
||||
|
||||
var imageName = HtmlEncode(evt.Payload.GetValueOrDefault("image_name")?.ToString() ?? "unknown");
|
||||
var imageDigest = HtmlEncode(evt.Payload.GetValueOrDefault("image_digest")?.ToString() ?? "unknown");
|
||||
var scanId = HtmlEncode(evt.Payload.GetValueOrDefault("scan_id")?.ToString() ?? "unknown");
|
||||
|
||||
sb.AppendLine(" <table style=\"width: 100%; border-collapse: collapse;\">");
|
||||
sb.AppendLine($" <tr><td style=\"padding: 8px 0; border-bottom: 1px solid #e5e7eb;\"><strong>Image:</strong></td><td style=\"padding: 8px 0; border-bottom: 1px solid #e5e7eb;\">{imageName}</td></tr>");
|
||||
sb.AppendLine($" <tr><td style=\"padding: 8px 0; border-bottom: 1px solid #e5e7eb;\"><strong>Digest:</strong></td><td style=\"padding: 8px 0; border-bottom: 1px solid #e5e7eb;\"><code>{imageDigest}</code></td></tr>");
|
||||
sb.AppendLine($" <tr><td style=\"padding: 8px 0; border-bottom: 1px solid #e5e7eb;\"><strong>Scan ID:</strong></td><td style=\"padding: 8px 0; border-bottom: 1px solid #e5e7eb;\">{scanId}</td></tr>");
|
||||
sb.AppendLine($" <tr><td style=\"padding: 8px 0;\"><strong>Scanned At:</strong></td><td style=\"padding: 8px 0;\">{evt.Timestamp:yyyy-MM-ddTHH:mm:ssZ}</td></tr>");
|
||||
sb.AppendLine(" </table>");
|
||||
|
||||
// Vulnerability summary
|
||||
if (evt.Payload.TryGetValue("vulnerabilities", out var vulns) && vulns is JsonElement vulnElement)
|
||||
{
|
||||
sb.AppendLine(" <h2>Vulnerability Summary</h2>");
|
||||
sb.AppendLine(" <table style=\"width: 100%; border-collapse: collapse; text-align: center;\">");
|
||||
sb.AppendLine(" <tr style=\"background: #f3f4f6;\"><th style=\"padding: 12px;\">Critical</th><th style=\"padding: 12px;\">High</th><th style=\"padding: 12px;\">Medium</th><th style=\"padding: 12px;\">Low</th></tr>");
|
||||
sb.AppendLine(" <tr>");
|
||||
|
||||
var critical = vulnElement.TryGetProperty("critical", out var c) ? c.GetInt32() : 0;
|
||||
var high = vulnElement.TryGetProperty("high", out var h) ? h.GetInt32() : 0;
|
||||
var medium = vulnElement.TryGetProperty("medium", out var m) ? m.GetInt32() : 0;
|
||||
var low = vulnElement.TryGetProperty("low", out var l) ? l.GetInt32() : 0;
|
||||
|
||||
var criticalBg = critical > 0 ? " background: #fecaca;" : "";
|
||||
var highBg = high > 0 ? " background: #fed7aa;" : "";
|
||||
|
||||
sb.AppendLine($" <td style=\"padding: 12px; font-size: 24px; font-weight: bold; color: #7f1d1d;{criticalBg}\">{critical}</td>");
|
||||
sb.AppendLine($" <td style=\"padding: 12px; font-size: 24px; font-weight: bold; color: #9a3412;{highBg}\">{high}</td>");
|
||||
sb.AppendLine($" <td style=\"padding: 12px; font-size: 24px; font-weight: bold; color: #a16207;\">{medium}</td>");
|
||||
sb.AppendLine($" <td style=\"padding: 12px; font-size: 24px; font-weight: bold; color: #166534;\">{low}</td>");
|
||||
sb.AppendLine(" </tr>");
|
||||
sb.AppendLine(" </table>");
|
||||
}
|
||||
|
||||
// Critical findings
|
||||
if (!isPassing && evt.Payload.TryGetValue("critical_findings", out var findings) && findings is JsonElement findingsElement)
|
||||
{
|
||||
sb.AppendLine(" <h2 style=\"color: #dc2626;\">Critical Vulnerabilities</h2>");
|
||||
foreach (var finding in findingsElement.EnumerateArray())
|
||||
{
|
||||
var cveId = finding.TryGetProperty("cve_id", out var cve) ? cve.GetString() : "Unknown";
|
||||
var pkg = finding.TryGetProperty("package", out var p) ? p.GetString() : "unknown";
|
||||
var title = finding.TryGetProperty("title", out var t) ? t.GetString() : "";
|
||||
var cvss = finding.TryGetProperty("cvss", out var cv) ? cv.GetDouble() : 0;
|
||||
|
||||
sb.AppendLine(" <div style=\"background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px; padding: 16px; margin-bottom: 12px;\">");
|
||||
sb.AppendLine($" <h3 style=\"margin: 0 0 8px 0; color: #991b1b;\">{HtmlEncode(cveId)} (CVSS {cvss})</h3>");
|
||||
sb.AppendLine($" <p style=\"margin: 0 0 8px 0;\"><strong>Package:</strong> {HtmlEncode(pkg)}</p>");
|
||||
sb.AppendLine($" <p style=\"margin: 0;\">{HtmlEncode(title)}</p>");
|
||||
sb.AppendLine(" </div>");
|
||||
}
|
||||
|
||||
sb.AppendLine(" <p style=\"margin-top: 20px; padding: 16px; background: #fef3c7; border-radius: 8px; color: #92400e;\">");
|
||||
sb.AppendLine(" <strong>Action Required:</strong> This image should not be deployed to production until the critical vulnerabilities are remediated.");
|
||||
sb.AppendLine(" </p>");
|
||||
}
|
||||
|
||||
sb.AppendLine(" <p style=\"margin-top: 20px; color: #6b7280; font-size: 14px;\">This is an automated message from StellaOps. Do not reply to this email.</p>");
|
||||
sb.AppendLine(" </div>");
|
||||
}
|
||||
|
||||
private static void FormatPolicyViolationBody(System.Text.StringBuilder sb, NotificationEvent evt)
|
||||
{
|
||||
sb.AppendLine(" <div style=\"background: #f59e0b; color: white; padding: 20px; border-radius: 8px 8px 0 0;\">");
|
||||
sb.AppendLine(" <h1 style=\"margin: 0; font-size: 24px;\">🚨 Policy Violation Detected</h1>");
|
||||
sb.AppendLine(" </div>");
|
||||
|
||||
sb.AppendLine(" <div style=\"border: 1px solid #e5e7eb; border-top: none; padding: 20px; border-radius: 0 0 8px 8px;\">");
|
||||
|
||||
var policyName = HtmlEncode(evt.Payload.GetValueOrDefault("policy_name")?.ToString() ?? "Unknown");
|
||||
var imageName = HtmlEncode(evt.Payload.GetValueOrDefault("image_name")?.ToString() ?? "unknown");
|
||||
var imageDigest = HtmlEncode(evt.Payload.GetValueOrDefault("image_digest")?.ToString() ?? "unknown");
|
||||
var violationType = HtmlEncode(evt.Payload.GetValueOrDefault("violation_type")?.ToString() ?? "unknown");
|
||||
var details = HtmlEncode(evt.Payload.GetValueOrDefault("details")?.ToString() ?? "");
|
||||
var remediation = HtmlEncode(evt.Payload.GetValueOrDefault("remediation")?.ToString() ?? "");
|
||||
|
||||
sb.AppendLine($" <h2 style=\"margin-top: 0;\">Policy: {policyName}</h2>");
|
||||
sb.AppendLine(" <table style=\"width: 100%; border-collapse: collapse;\">");
|
||||
sb.AppendLine($" <tr><td style=\"padding: 8px 0; border-bottom: 1px solid #e5e7eb;\"><strong>Image:</strong></td><td style=\"padding: 8px 0; border-bottom: 1px solid #e5e7eb;\">{imageName}</td></tr>");
|
||||
sb.AppendLine($" <tr><td style=\"padding: 8px 0; border-bottom: 1px solid #e5e7eb;\"><strong>Digest:</strong></td><td style=\"padding: 8px 0; border-bottom: 1px solid #e5e7eb;\"><code>{imageDigest}</code></td></tr>");
|
||||
sb.AppendLine($" <tr><td style=\"padding: 8px 0; border-bottom: 1px solid #e5e7eb;\"><strong>Violation Type:</strong></td><td style=\"padding: 8px 0; border-bottom: 1px solid #e5e7eb;\">{violationType}</td></tr>");
|
||||
sb.AppendLine($" <tr><td style=\"padding: 8px 0;\"><strong>Detected At:</strong></td><td style=\"padding: 8px 0;\">{evt.Timestamp:yyyy-MM-ddTHH:mm:ssZ}</td></tr>");
|
||||
sb.AppendLine(" </table>");
|
||||
|
||||
if (!string.IsNullOrEmpty(details))
|
||||
{
|
||||
sb.AppendLine(" <h3>Details</h3>");
|
||||
sb.AppendLine($" <p style=\"background: #fef3c7; padding: 16px; border-radius: 8px; color: #92400e;\">{details}</p>");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(remediation))
|
||||
{
|
||||
sb.AppendLine(" <h3>Remediation</h3>");
|
||||
sb.AppendLine($" <p style=\"background: #d1fae5; padding: 16px; border-radius: 8px; color: #065f46;\">{remediation}</p>");
|
||||
}
|
||||
|
||||
sb.AppendLine(" <p style=\"margin-top: 20px; color: #6b7280; font-size: 14px;\">This is an automated message from StellaOps. Do not reply to this email.</p>");
|
||||
sb.AppendLine(" </div>");
|
||||
}
|
||||
|
||||
private static string HtmlEncode(string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return "";
|
||||
return value
|
||||
.Replace("&", "&")
|
||||
.Replace("<", "<")
|
||||
.Replace(">", ">")
|
||||
.Replace("\"", """)
|
||||
.Replace("'", "'");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
Reference in New Issue
Block a user