product advisories, stella router improval, tests streghthening
This commit is contained in:
@@ -0,0 +1,660 @@
|
||||
// ---------------------------------------------------------------------
|
||||
// <copyright file="SlackConnectorErrorTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
// <summary>
|
||||
// Error handling tests for Slack connector: API unavailable → retry;
|
||||
// invalid channel → fail gracefully.
|
||||
// </summary>
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
using System.Net;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Slack.Tests.ErrorHandling;
|
||||
|
||||
/// <summary>
|
||||
/// Error handling tests for Slack connector.
|
||||
/// Verifies graceful handling of Slack API failures and invalid channels.
|
||||
/// </summary>
|
||||
[Trait("Category", "ErrorHandling")]
|
||||
[Trait("Sprint", "5100-0009-0009")]
|
||||
public sealed class SlackConnectorErrorTests
|
||||
{
|
||||
#region API Unavailable Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Slack API unavailable triggers retry.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SlackApiUnavailable_TriggersRetry()
|
||||
{
|
||||
// Arrange
|
||||
var httpClient = new FailingSlackClient(HttpStatusCode.ServiceUnavailable, "service_unavailable");
|
||||
var connector = new SlackConnector(httpClient, new SlackConnectorOptions
|
||||
{
|
||||
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("API unavailable is transient");
|
||||
result.ErrorCode.Should().Be("SERVICE_UNAVAILABLE");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Slack rate limiting triggers retry with appropriate delay.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SlackRateLimited_TriggersRetryWithDelay()
|
||||
{
|
||||
// Arrange
|
||||
var httpClient = new FailingSlackClient(
|
||||
HttpStatusCode.TooManyRequests,
|
||||
"ratelimited",
|
||||
retryAfterSeconds: 30);
|
||||
var connector = new SlackConnector(httpClient, new SlackConnectorOptions());
|
||||
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(30000, "should respect Retry-After header");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that network timeout triggers retry.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task NetworkTimeout_TriggersRetry()
|
||||
{
|
||||
// Arrange
|
||||
var httpClient = new TimeoutSlackClient();
|
||||
var connector = new SlackConnector(httpClient, new SlackConnectorOptions
|
||||
{
|
||||
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 transient");
|
||||
result.ErrorCode.Should().Be("TIMEOUT");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Invalid Channel Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that invalid channel fails gracefully without retry.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InvalidChannel_FailsGracefully()
|
||||
{
|
||||
// Arrange
|
||||
var httpClient = new FailingSlackClient(HttpStatusCode.OK, "channel_not_found");
|
||||
var connector = new SlackConnector(httpClient, new SlackConnectorOptions());
|
||||
var notification = CreateTestNotification(channel: "#nonexistent-channel");
|
||||
|
||||
// Act
|
||||
var result = await connector.SendAsync(notification, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.ShouldRetry.Should().BeFalse("invalid channel is permanent");
|
||||
result.ErrorCode.Should().Be("CHANNEL_NOT_FOUND");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that archived channel fails gracefully without retry.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ArchivedChannel_FailsGracefully()
|
||||
{
|
||||
// Arrange
|
||||
var httpClient = new FailingSlackClient(HttpStatusCode.OK, "is_archived");
|
||||
var connector = new SlackConnector(httpClient, new SlackConnectorOptions());
|
||||
var notification = CreateTestNotification();
|
||||
|
||||
// Act
|
||||
var result = await connector.SendAsync(notification, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.ShouldRetry.Should().BeFalse("archived channel is permanent");
|
||||
result.ErrorCode.Should().Be("CHANNEL_ARCHIVED");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that not-in-channel error fails gracefully.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task NotInChannel_FailsGracefully()
|
||||
{
|
||||
// Arrange
|
||||
var httpClient = new FailingSlackClient(HttpStatusCode.OK, "not_in_channel");
|
||||
var connector = new SlackConnector(httpClient, new SlackConnectorOptions());
|
||||
var notification = CreateTestNotification();
|
||||
|
||||
// Act
|
||||
var result = await connector.SendAsync(notification, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.ShouldRetry.Should().BeFalse("bot not in channel is permanent until config change");
|
||||
result.ErrorCode.Should().Be("NOT_IN_CHANNEL");
|
||||
result.ErrorMessage.Should().Contain("invite");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Authentication Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that invalid token fails without retry.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InvalidToken_FailsWithoutRetry()
|
||||
{
|
||||
// Arrange
|
||||
var httpClient = new FailingSlackClient(HttpStatusCode.OK, "invalid_auth");
|
||||
var connector = new SlackConnector(httpClient, new SlackConnectorOptions());
|
||||
var notification = CreateTestNotification();
|
||||
|
||||
// Act
|
||||
var result = await connector.SendAsync(notification, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.ShouldRetry.Should().BeFalse("invalid token is permanent");
|
||||
result.ErrorCode.Should().Be("INVALID_AUTH");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that token revoked fails without retry.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task TokenRevoked_FailsWithoutRetry()
|
||||
{
|
||||
// Arrange
|
||||
var httpClient = new FailingSlackClient(HttpStatusCode.OK, "token_revoked");
|
||||
var connector = new SlackConnector(httpClient, new SlackConnectorOptions());
|
||||
var notification = CreateTestNotification();
|
||||
|
||||
// Act
|
||||
var result = await connector.SendAsync(notification, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.ShouldRetry.Should().BeFalse();
|
||||
result.ErrorCode.Should().Be("TOKEN_REVOKED");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that missing scope fails without retry.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MissingScope_FailsWithoutRetry()
|
||||
{
|
||||
// Arrange
|
||||
var httpClient = new FailingSlackClient(HttpStatusCode.OK, "missing_scope");
|
||||
var connector = new SlackConnector(httpClient, new SlackConnectorOptions());
|
||||
var notification = CreateTestNotification();
|
||||
|
||||
// Act
|
||||
var result = await connector.SendAsync(notification, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.ShouldRetry.Should().BeFalse();
|
||||
result.ErrorCode.Should().Be("MISSING_SCOPE");
|
||||
result.ErrorMessage.Should().Contain("chat:write");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that empty channel fails validation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EmptyChannel_FailsValidation()
|
||||
{
|
||||
// Arrange
|
||||
var httpClient = new SucceedingSlackClient();
|
||||
var connector = new SlackConnector(httpClient, new SlackConnectorOptions());
|
||||
var notification = new SlackNotification
|
||||
{
|
||||
NotificationId = "notif-001",
|
||||
Channel = "", // Empty
|
||||
Text = "Test",
|
||||
Blocks = new List<SlackBlock>()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await connector.SendAsync(notification, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.ShouldRetry.Should().BeFalse();
|
||||
result.ErrorCode.Should().Be("VALIDATION_FAILED");
|
||||
httpClient.SendAttempts.Should().Be(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that message too long fails validation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MessageTooLong_FailsValidation()
|
||||
{
|
||||
// Arrange
|
||||
var httpClient = new SucceedingSlackClient();
|
||||
var connector = new SlackConnector(httpClient, new SlackConnectorOptions());
|
||||
var notification = new SlackNotification
|
||||
{
|
||||
NotificationId = "notif-001",
|
||||
Channel = "#test",
|
||||
Text = new string('x', 50000), // Slack limit is ~40000 characters
|
||||
Blocks = new List<SlackBlock>()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await connector.SendAsync(notification, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.ErrorCode.Should().Be("MESSAGE_TOO_LONG");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that too many blocks fails validation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task TooManyBlocks_FailsValidation()
|
||||
{
|
||||
// Arrange
|
||||
var httpClient = new SucceedingSlackClient();
|
||||
var connector = new SlackConnector(httpClient, new SlackConnectorOptions());
|
||||
var notification = new SlackNotification
|
||||
{
|
||||
NotificationId = "notif-001",
|
||||
Channel = "#test",
|
||||
Text = "Test",
|
||||
Blocks = Enumerable.Range(0, 60).Select(i => new SlackBlock { Type = "section" }).ToList() // Slack limit is 50
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await connector.SendAsync(notification, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.ErrorCode.Should().Be("TOO_MANY_BLOCKS");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cancellation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that cancellation is respected.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Cancellation_StopsSend()
|
||||
{
|
||||
// Arrange
|
||||
var httpClient = new SlowSlackClient(TimeSpan.FromSeconds(10));
|
||||
var connector = new SlackConnector(httpClient, new SlackConnectorOptions());
|
||||
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();
|
||||
result.ErrorCode.Should().Be("CANCELLED");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Classification Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies Slack error codes are correctly classified.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("channel_not_found", false, "CHANNEL_NOT_FOUND")]
|
||||
[InlineData("is_archived", false, "CHANNEL_ARCHIVED")]
|
||||
[InlineData("not_in_channel", false, "NOT_IN_CHANNEL")]
|
||||
[InlineData("invalid_auth", false, "INVALID_AUTH")]
|
||||
[InlineData("token_revoked", false, "TOKEN_REVOKED")]
|
||||
[InlineData("missing_scope", false, "MISSING_SCOPE")]
|
||||
[InlineData("ratelimited", true, "RATE_LIMITED")]
|
||||
[InlineData("service_unavailable", true, "SERVICE_UNAVAILABLE")]
|
||||
[InlineData("internal_error", true, "INTERNAL_ERROR")]
|
||||
[InlineData("request_timeout", true, "TIMEOUT")]
|
||||
public async Task SlackErrorCodes_AreCorrectlyClassified(string slackError, bool shouldRetry, string expectedCode)
|
||||
{
|
||||
// Arrange
|
||||
var httpClient = new FailingSlackClient(HttpStatusCode.OK, slackError);
|
||||
var connector = new SlackConnector(httpClient, new SlackConnectorOptions());
|
||||
var notification = CreateTestNotification();
|
||||
|
||||
// Act
|
||||
var result = await connector.SendAsync(notification, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.ShouldRetry.Should().Be(shouldRetry);
|
||||
result.ErrorCode.Should().Be(expectedCode);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static SlackNotification CreateTestNotification(string? channel = null)
|
||||
{
|
||||
return new SlackNotification
|
||||
{
|
||||
NotificationId = $"notif-{Guid.NewGuid():N}",
|
||||
Channel = channel ?? "#security-alerts",
|
||||
Text = "Test notification",
|
||||
Blocks = new List<SlackBlock>
|
||||
{
|
||||
new() { Type = "section", Text = new SlackTextObject { Text = "Test" } }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Test Doubles
|
||||
|
||||
/// <summary>
|
||||
/// Fake Slack client that always fails.
|
||||
/// </summary>
|
||||
internal sealed class FailingSlackClient : ISlackClient
|
||||
{
|
||||
private readonly HttpStatusCode _statusCode;
|
||||
private readonly string _slackError;
|
||||
private readonly int _retryAfterSeconds;
|
||||
|
||||
public FailingSlackClient(HttpStatusCode statusCode, string slackError, int retryAfterSeconds = 0)
|
||||
{
|
||||
_statusCode = statusCode;
|
||||
_slackError = slackError;
|
||||
_retryAfterSeconds = retryAfterSeconds;
|
||||
}
|
||||
|
||||
public Task<SlackApiResponse> PostMessageAsync(SlackNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new SlackApiResponse
|
||||
{
|
||||
Ok = false,
|
||||
Error = _slackError,
|
||||
HttpStatusCode = _statusCode,
|
||||
RetryAfterSeconds = _retryAfterSeconds
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake Slack client that times out.
|
||||
/// </summary>
|
||||
internal sealed class TimeoutSlackClient : ISlackClient
|
||||
{
|
||||
public Task<SlackApiResponse> PostMessageAsync(SlackNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new TaskCanceledException("The request was canceled due to the configured HttpClient.Timeout");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake Slack client that is slow (for cancellation tests).
|
||||
/// </summary>
|
||||
internal sealed class SlowSlackClient : ISlackClient
|
||||
{
|
||||
private readonly TimeSpan _delay;
|
||||
|
||||
public SlowSlackClient(TimeSpan delay)
|
||||
{
|
||||
_delay = delay;
|
||||
}
|
||||
|
||||
public async Task<SlackApiResponse> PostMessageAsync(SlackNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Delay(_delay, cancellationToken);
|
||||
return new SlackApiResponse { Ok = true };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake Slack client that succeeds.
|
||||
/// </summary>
|
||||
internal sealed class SucceedingSlackClient : ISlackClient
|
||||
{
|
||||
public int SendAttempts { get; private set; }
|
||||
|
||||
public Task<SlackApiResponse> PostMessageAsync(SlackNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
SendAttempts++;
|
||||
return Task.FromResult(new SlackApiResponse
|
||||
{
|
||||
Ok = true,
|
||||
Ts = "1234567890.123456",
|
||||
Channel = notification.Channel
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slack client interface for testing.
|
||||
/// </summary>
|
||||
internal interface ISlackClient
|
||||
{
|
||||
Task<SlackApiResponse> PostMessageAsync(SlackNotification notification, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slack API response model.
|
||||
/// </summary>
|
||||
internal sealed class SlackApiResponse
|
||||
{
|
||||
public bool Ok { get; set; }
|
||||
public string? Error { get; set; }
|
||||
public string? Ts { get; set; }
|
||||
public string? Channel { get; set; }
|
||||
public HttpStatusCode HttpStatusCode { get; set; } = HttpStatusCode.OK;
|
||||
public int RetryAfterSeconds { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slack notification model.
|
||||
/// </summary>
|
||||
internal sealed class SlackNotification
|
||||
{
|
||||
public required string NotificationId { get; set; }
|
||||
public required string Channel { get; set; }
|
||||
public required string Text { get; set; }
|
||||
public List<SlackBlock> Blocks { get; set; } = new();
|
||||
public string? ThreadTs { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slack block model.
|
||||
/// </summary>
|
||||
internal sealed class SlackBlock
|
||||
{
|
||||
public required string Type { get; set; }
|
||||
public SlackTextObject? Text { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slack text object model.
|
||||
/// </summary>
|
||||
internal sealed class SlackTextObject
|
||||
{
|
||||
public string Type { get; set; } = "mrkdwn";
|
||||
public string? Text { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slack connector options.
|
||||
/// </summary>
|
||||
internal sealed class SlackConnectorOptions
|
||||
{
|
||||
public int MaxRetries { get; set; } = 3;
|
||||
public int RetryDelayMs { get; set; } = 1000;
|
||||
public int TimeoutMs { get; set; } = 30000;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slack send result.
|
||||
/// </summary>
|
||||
internal sealed class SlackSendResult
|
||||
{
|
||||
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 string? MessageTs { get; set; }
|
||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||
public string? NotificationId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slack connector for testing.
|
||||
/// </summary>
|
||||
internal sealed class SlackConnector
|
||||
{
|
||||
private readonly ISlackClient _client;
|
||||
private readonly SlackConnectorOptions _options;
|
||||
private const int MaxMessageLength = 40000;
|
||||
private const int MaxBlocks = 50;
|
||||
|
||||
public SlackConnector(ISlackClient client, SlackConnectorOptions options)
|
||||
{
|
||||
_client = client;
|
||||
_options = options;
|
||||
}
|
||||
|
||||
public async Task<SlackSendResult> SendAsync(SlackNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new SlackSendResult
|
||||
{
|
||||
NotificationId = notification.NotificationId,
|
||||
Timestamp = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// Validate
|
||||
var validationError = Validate(notification);
|
||||
if (validationError != null)
|
||||
{
|
||||
result.Success = false;
|
||||
result.ShouldRetry = false;
|
||||
result.ErrorCode = validationError.Code;
|
||||
result.ErrorMessage = validationError.Message;
|
||||
return result;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _client.PostMessageAsync(notification, cancellationToken);
|
||||
|
||||
if (response.Ok)
|
||||
{
|
||||
result.Success = true;
|
||||
result.MessageTs = response.Ts;
|
||||
return result;
|
||||
}
|
||||
|
||||
return ClassifySlackError(result, response);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
result.Success = false;
|
||||
result.ShouldRetry = true;
|
||||
result.ErrorCode = "TIMEOUT";
|
||||
return result;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
result.Success = false;
|
||||
result.ShouldRetry = true;
|
||||
result.ErrorCode = "CANCELLED";
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Success = false;
|
||||
result.ShouldRetry = true;
|
||||
result.ErrorCode = "UNKNOWN_ERROR";
|
||||
result.ErrorMessage = ex.Message;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private static (string Code, string Message)? Validate(SlackNotification notification)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(notification.Channel))
|
||||
return ("VALIDATION_FAILED", "Channel is required");
|
||||
|
||||
if (notification.Text?.Length > MaxMessageLength)
|
||||
return ("MESSAGE_TOO_LONG", $"Message exceeds {MaxMessageLength} character limit");
|
||||
|
||||
if (notification.Blocks?.Count > MaxBlocks)
|
||||
return ("TOO_MANY_BLOCKS", $"Message exceeds {MaxBlocks} block limit");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private SlackSendResult ClassifySlackError(SlackSendResult result, SlackApiResponse response)
|
||||
{
|
||||
result.Success = false;
|
||||
|
||||
var (code, shouldRetry, message) = response.Error switch
|
||||
{
|
||||
"channel_not_found" => ("CHANNEL_NOT_FOUND", false, "Channel not found"),
|
||||
"is_archived" => ("CHANNEL_ARCHIVED", false, "Channel is archived"),
|
||||
"not_in_channel" => ("NOT_IN_CHANNEL", false, "Bot is not in channel. Please invite the bot to the channel."),
|
||||
"invalid_auth" => ("INVALID_AUTH", false, "Invalid authentication token"),
|
||||
"token_revoked" => ("TOKEN_REVOKED", false, "Token has been revoked"),
|
||||
"missing_scope" => ("MISSING_SCOPE", false, "Missing required scope: chat:write"),
|
||||
"ratelimited" => ("RATE_LIMITED", true, "Rate limited"),
|
||||
"service_unavailable" => ("SERVICE_UNAVAILABLE", true, "Service unavailable"),
|
||||
"internal_error" => ("INTERNAL_ERROR", true, "Slack internal error"),
|
||||
"request_timeout" => ("TIMEOUT", true, "Request timed out"),
|
||||
_ => ("UNKNOWN_ERROR", true, response.Error ?? "Unknown error")
|
||||
};
|
||||
|
||||
result.ErrorCode = code;
|
||||
result.ShouldRetry = shouldRetry;
|
||||
result.ErrorMessage = message;
|
||||
|
||||
if (response.RetryAfterSeconds > 0)
|
||||
result.RetryAfterMs = response.RetryAfterSeconds * 1000;
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"channel": "#security-alerts",
|
||||
"text": "🚨 Policy Violation - No Root Containers - acme/backend:v3.1.0",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "header",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": "🚨 Policy Violation Detected",
|
||||
"emoji": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"fields": [
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*Policy:*\nNo Root Containers"
|
||||
},
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*Severity:*\n🔴 High"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"fields": [
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*Image:*\nacme/backend:v3.1.0"
|
||||
},
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*Violation:*\ncontainer_runs_as_root"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "divider"
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "*Details:*\nContainer is configured to run as root user (UID 0). This violates security policy."
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "*Remediation:*\n```\nUSER 1000:1000\n```\nUpdate Dockerfile to use a non-root user: USER 1000:1000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "actions",
|
||||
"elements": [
|
||||
{
|
||||
"type": "button",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": "View Policy",
|
||||
"emoji": true
|
||||
},
|
||||
"url": "https://stellaops.acme.example.com/policies/policy-no-root-001",
|
||||
"action_id": "view_policy"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "context",
|
||||
"elements": [
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "Detected at 2026-12-19T12:15:00Z by StellaOps"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"channel": "#security-alerts",
|
||||
"text": "🚨 CRITICAL - Scan Failed - acme/api-server:v2.0.0 - 3 critical vulnerabilities found",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "header",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": "🚨 Critical Vulnerabilities Found",
|
||||
"emoji": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"fields": [
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*Image:*\nacme/api-server:v2.0.0"
|
||||
},
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*Digest:*\n`sha256:def456...`"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "*Vulnerability Summary:*\n🔴 Critical: *3* | 🟠 High: *5* | 🟡 Medium: 12 | 🟢 Low: 8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "divider"
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "*Critical Findings:*"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "🔴 *CVE-2026-1234* (CVSS 9.8)\n`openssl` 1.1.1k → 1.1.1l\nRemote code execution in OpenSSL"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "🔴 *CVE-2026-5678* (CVSS 9.1)\n`libcurl` 7.79.0 → 7.80.0\nAuthentication bypass in libcurl"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "context",
|
||||
"elements": [
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "⚠️ *Action Required:* This image should not be deployed to production."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "actions",
|
||||
"elements": [
|
||||
{
|
||||
"type": "button",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": "View Full Report",
|
||||
"emoji": true
|
||||
},
|
||||
"style": "danger",
|
||||
"url": "https://stellaops.acme.example.com/scans/scan-critical-001",
|
||||
"action_id": "view_scan_details"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "context",
|
||||
"elements": [
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "cc <@U12345678> <@U87654321> | Scanned at 2026-12-19T11:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"channel": "#security-alerts",
|
||||
"text": "✅ Scan Passed - acme/webapp:v1.2.3",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "header",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": "✅ Scan Passed",
|
||||
"emoji": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"fields": [
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*Image:*\nacme/webapp:v1.2.3"
|
||||
},
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*Digest:*\n`sha256:abc123...`"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"fields": [
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*Scan ID:*\nscan-789abc"
|
||||
},
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*Duration:*\n45s"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "*Vulnerability Summary:*\n🔴 Critical: 0 | 🟠 High: 0 | 🟡 Medium: 2 | 🟢 Low: 5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "actions",
|
||||
"elements": [
|
||||
{
|
||||
"type": "button",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": "View Details",
|
||||
"emoji": true
|
||||
},
|
||||
"url": "https://stellaops.acme.example.com/scans/scan-789abc",
|
||||
"action_id": "view_scan_details"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "context",
|
||||
"elements": [
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "Scanned at 2026-12-19T10:30:00Z by StellaOps"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"notification_id": "notif-slack-003",
|
||||
"tenant_id": "tenant-acme",
|
||||
"channel": "slack",
|
||||
"event_type": "policy.violation",
|
||||
"timestamp": "2026-12-19T12:15:00Z",
|
||||
"payload": {
|
||||
"scan_id": "scan-policy-001",
|
||||
"image_name": "acme/backend:v3.1.0",
|
||||
"image_digest": "sha256:policy123456789012345678901234567890123456789012345678901234abcd",
|
||||
"policy_name": "No Root Containers",
|
||||
"policy_id": "policy-no-root-001",
|
||||
"violation_type": "container_runs_as_root",
|
||||
"severity": "high",
|
||||
"details": "Container is configured to run as root user (UID 0). This violates security policy.",
|
||||
"remediation": "Update Dockerfile to use a non-root user: USER 1000:1000",
|
||||
"policy_url": "https://stellaops.acme.example.com/policies/policy-no-root-001"
|
||||
},
|
||||
"recipient": {
|
||||
"slack_channel": "#security-alerts",
|
||||
"workspace_id": "T12345678"
|
||||
},
|
||||
"metadata": {
|
||||
"priority": "high",
|
||||
"thread_ts": null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"notification_id": "notif-slack-002",
|
||||
"tenant_id": "tenant-acme",
|
||||
"channel": "slack",
|
||||
"event_type": "scan.completed",
|
||||
"timestamp": "2026-12-19T11:00:00Z",
|
||||
"payload": {
|
||||
"scan_id": "scan-critical-001",
|
||||
"image_name": "acme/api-server:v2.0.0",
|
||||
"image_digest": "sha256:def456789012345678901234567890123456789012345678901234567890abcd",
|
||||
"verdict": "fail",
|
||||
"vulnerabilities": {
|
||||
"critical": 3,
|
||||
"high": 5,
|
||||
"medium": 12,
|
||||
"low": 8
|
||||
},
|
||||
"critical_findings": [
|
||||
{
|
||||
"cve_id": "CVE-2026-1234",
|
||||
"package": "openssl",
|
||||
"version": "1.1.1k",
|
||||
"fixed_version": "1.1.1l",
|
||||
"cvss": 9.8,
|
||||
"title": "Remote code execution in OpenSSL"
|
||||
},
|
||||
{
|
||||
"cve_id": "CVE-2026-5678",
|
||||
"package": "libcurl",
|
||||
"version": "7.79.0",
|
||||
"fixed_version": "7.80.0",
|
||||
"cvss": 9.1,
|
||||
"title": "Authentication bypass in libcurl"
|
||||
}
|
||||
],
|
||||
"scan_duration_ms": 67000,
|
||||
"findings_url": "https://stellaops.acme.example.com/scans/scan-critical-001"
|
||||
},
|
||||
"recipient": {
|
||||
"slack_channel": "#security-alerts",
|
||||
"workspace_id": "T12345678"
|
||||
},
|
||||
"metadata": {
|
||||
"priority": "high",
|
||||
"mention_users": ["U12345678", "U87654321"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"notification_id": "notif-slack-001",
|
||||
"tenant_id": "tenant-acme",
|
||||
"channel": "slack",
|
||||
"event_type": "scan.completed",
|
||||
"timestamp": "2026-12-19T10:30:00Z",
|
||||
"payload": {
|
||||
"scan_id": "scan-789abc",
|
||||
"image_name": "acme/webapp:v1.2.3",
|
||||
"image_digest": "sha256:abc123def456789012345678901234567890123456789012345678901234abcd",
|
||||
"verdict": "pass",
|
||||
"vulnerabilities": {
|
||||
"critical": 0,
|
||||
"high": 0,
|
||||
"medium": 2,
|
||||
"low": 5
|
||||
},
|
||||
"scan_duration_ms": 45000,
|
||||
"findings_url": "https://stellaops.acme.example.com/scans/scan-789abc"
|
||||
},
|
||||
"recipient": {
|
||||
"slack_channel": "#security-alerts",
|
||||
"workspace_id": "T12345678"
|
||||
},
|
||||
"metadata": {
|
||||
"priority": "normal",
|
||||
"thread_ts": null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,757 @@
|
||||
// ---------------------------------------------------------------------
|
||||
// <copyright file="SlackConnectorSnapshotTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
// <summary>
|
||||
// Payload formatting snapshot tests for Slack connector: event → Slack Block Kit → assert snapshot.
|
||||
// </summary>
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Notify.Connectors.Slack.Tests.Snapshot;
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot tests for Slack connector payload formatting.
|
||||
/// Verifies event → Slack Block Kit output matches expected snapshots.
|
||||
/// </summary>
|
||||
[Trait("Category", "Snapshot")]
|
||||
[Trait("Sprint", "5100-0009-0009")]
|
||||
public sealed class SlackConnectorSnapshotTests
|
||||
{
|
||||
private readonly string _fixturesPath;
|
||||
private readonly string _expectedPath;
|
||||
private readonly SlackFormatter _formatter;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public SlackConnectorSnapshotTests()
|
||||
{
|
||||
var assemblyDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
|
||||
_fixturesPath = Path.Combine(assemblyDir, "Fixtures", "slack");
|
||||
_expectedPath = Path.Combine(assemblyDir, "Expected");
|
||||
_formatter = new SlackFormatter();
|
||||
}
|
||||
|
||||
#region Scan Completed Pass Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies scan completed (pass) event formats to expected Slack message.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ScanCompletedPass_FormatsToExpectedSlackMessage()
|
||||
{
|
||||
// Arrange
|
||||
var eventJson = await LoadFixtureAsync("scan_completed_pass.json");
|
||||
var expected = await LoadExpectedJsonAsync("scan_completed_pass.slack.json");
|
||||
var notificationEvent = JsonSerializer.Deserialize<SlackNotificationEvent>(eventJson, JsonOptions)!;
|
||||
|
||||
// Act
|
||||
var slackMessage = _formatter.Format(notificationEvent);
|
||||
|
||||
// Assert
|
||||
slackMessage.Channel.Should().Be("#security-alerts");
|
||||
slackMessage.Text.Should().Contain("Scan Passed");
|
||||
slackMessage.Text.Should().Contain("acme/webapp:v1.2.3");
|
||||
slackMessage.Blocks.Should().NotBeEmpty();
|
||||
|
||||
// Verify header block
|
||||
var headerBlock = slackMessage.Blocks.FirstOrDefault(b => b.Type == "header");
|
||||
headerBlock.Should().NotBeNull();
|
||||
|
||||
// Verify actions block with button
|
||||
var actionsBlock = slackMessage.Blocks.FirstOrDefault(b => b.Type == "actions");
|
||||
actionsBlock.Should().NotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies scan completed (pass) includes vulnerability summary.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ScanCompletedPass_IncludesVulnerabilitySummary()
|
||||
{
|
||||
// Arrange
|
||||
var eventJson = await LoadFixtureAsync("scan_completed_pass.json");
|
||||
var notificationEvent = JsonSerializer.Deserialize<SlackNotificationEvent>(eventJson, JsonOptions)!;
|
||||
|
||||
// Act
|
||||
var slackMessage = _formatter.Format(notificationEvent);
|
||||
|
||||
// Assert - find section with vulnerability counts
|
||||
var vulnSection = slackMessage.Blocks
|
||||
.Where(b => b.Type == "section")
|
||||
.SelectMany(b => b.Text != null ? new[] { b.Text.Text ?? "" } : Array.Empty<string>())
|
||||
.FirstOrDefault(t => t.Contains("Vulnerability"));
|
||||
|
||||
vulnSection.Should().NotBeNull();
|
||||
vulnSection.Should().Contain("Critical: 0");
|
||||
vulnSection.Should().Contain("Medium: 2");
|
||||
vulnSection.Should().Contain("Low: 5");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Scan Completed Fail Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies scan completed (fail) event formats to expected Slack message.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ScanCompletedFail_FormatsToExpectedSlackMessage()
|
||||
{
|
||||
// Arrange
|
||||
var eventJson = await LoadFixtureAsync("scan_completed_fail.json");
|
||||
var expected = await LoadExpectedJsonAsync("scan_completed_fail.slack.json");
|
||||
var notificationEvent = JsonSerializer.Deserialize<SlackNotificationEvent>(eventJson, JsonOptions)!;
|
||||
|
||||
// Act
|
||||
var slackMessage = _formatter.Format(notificationEvent);
|
||||
|
||||
// Assert
|
||||
slackMessage.Channel.Should().Be("#security-alerts");
|
||||
slackMessage.Text.Should().Contain("CRITICAL");
|
||||
slackMessage.Text.Should().Contain("Scan Failed");
|
||||
slackMessage.Blocks.Should().NotBeEmpty();
|
||||
|
||||
// Verify danger-styled button
|
||||
var actionsBlock = slackMessage.Blocks.FirstOrDefault(b => b.Type == "actions");
|
||||
actionsBlock.Should().NotBeNull();
|
||||
actionsBlock!.Elements.Should().Contain(e => e.Style == "danger");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies scan completed (fail) includes critical CVE details.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ScanCompletedFail_IncludesCriticalCVEDetails()
|
||||
{
|
||||
// Arrange
|
||||
var eventJson = await LoadFixtureAsync("scan_completed_fail.json");
|
||||
var notificationEvent = JsonSerializer.Deserialize<SlackNotificationEvent>(eventJson, JsonOptions)!;
|
||||
|
||||
// Act
|
||||
var slackMessage = _formatter.Format(notificationEvent);
|
||||
|
||||
// Assert - find CVE sections
|
||||
var blocksJson = JsonSerializer.Serialize(slackMessage.Blocks, JsonOptions);
|
||||
blocksJson.Should().Contain("CVE-2026-1234");
|
||||
blocksJson.Should().Contain("CVE-2026-5678");
|
||||
blocksJson.Should().Contain("openssl");
|
||||
blocksJson.Should().Contain("CVSS 9.8");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies scan completed (fail) mentions configured users.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ScanCompletedFail_MentionsConfiguredUsers()
|
||||
{
|
||||
// Arrange
|
||||
var eventJson = await LoadFixtureAsync("scan_completed_fail.json");
|
||||
var notificationEvent = JsonSerializer.Deserialize<SlackNotificationEvent>(eventJson, JsonOptions)!;
|
||||
|
||||
// Act
|
||||
var slackMessage = _formatter.Format(notificationEvent);
|
||||
|
||||
// Assert - find context block with mentions
|
||||
var contextBlock = slackMessage.Blocks.LastOrDefault(b => b.Type == "context");
|
||||
contextBlock.Should().NotBeNull();
|
||||
var contextJson = JsonSerializer.Serialize(contextBlock, JsonOptions);
|
||||
contextJson.Should().Contain("<@U12345678>");
|
||||
contextJson.Should().Contain("<@U87654321>");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Policy Violation Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies policy violation event formats to expected Slack message.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task PolicyViolation_FormatsToExpectedSlackMessage()
|
||||
{
|
||||
// Arrange
|
||||
var eventJson = await LoadFixtureAsync("policy_violation.json");
|
||||
var expected = await LoadExpectedJsonAsync("policy_violation.slack.json");
|
||||
var notificationEvent = JsonSerializer.Deserialize<SlackNotificationEvent>(eventJson, JsonOptions)!;
|
||||
|
||||
// Act
|
||||
var slackMessage = _formatter.Format(notificationEvent);
|
||||
|
||||
// Assert
|
||||
slackMessage.Channel.Should().Be("#security-alerts");
|
||||
slackMessage.Text.Should().Contain("Policy Violation");
|
||||
slackMessage.Text.Should().Contain("No Root Containers");
|
||||
slackMessage.Blocks.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
/// <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<SlackNotificationEvent>(eventJson, JsonOptions)!;
|
||||
|
||||
// Act
|
||||
var slackMessage = _formatter.Format(notificationEvent);
|
||||
|
||||
// Assert
|
||||
var blocksJson = JsonSerializer.Serialize(slackMessage.Blocks, JsonOptions);
|
||||
blocksJson.Should().Contain("Remediation");
|
||||
blocksJson.Should().Contain("USER 1000:1000");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Block Kit Structure Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies all messages follow Block Kit structure.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("scan_completed_pass.json")]
|
||||
[InlineData("scan_completed_fail.json")]
|
||||
[InlineData("policy_violation.json")]
|
||||
public async Task AllMessages_FollowBlockKitStructure(string fixtureFile)
|
||||
{
|
||||
// Arrange
|
||||
var eventJson = await LoadFixtureAsync(fixtureFile);
|
||||
var notificationEvent = JsonSerializer.Deserialize<SlackNotificationEvent>(eventJson, JsonOptions)!;
|
||||
|
||||
// Act
|
||||
var slackMessage = _formatter.Format(notificationEvent);
|
||||
|
||||
// Assert
|
||||
slackMessage.Blocks.Should().NotBeEmpty();
|
||||
slackMessage.Blocks.Should().AllSatisfy(b =>
|
||||
{
|
||||
b.Type.Should().NotBeNullOrWhiteSpace();
|
||||
new[] { "header", "section", "divider", "actions", "context" }.Should().Contain(b.Type);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies fallback text is always set for accessibility.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("scan_completed_pass.json")]
|
||||
[InlineData("scan_completed_fail.json")]
|
||||
[InlineData("policy_violation.json")]
|
||||
public async Task AllMessages_HaveFallbackText(string fixtureFile)
|
||||
{
|
||||
// Arrange
|
||||
var eventJson = await LoadFixtureAsync(fixtureFile);
|
||||
var notificationEvent = JsonSerializer.Deserialize<SlackNotificationEvent>(eventJson, JsonOptions)!;
|
||||
|
||||
// Act
|
||||
var slackMessage = _formatter.Format(notificationEvent);
|
||||
|
||||
// Assert - text field is required for notifications
|
||||
slackMessage.Text.Should().NotBeNullOrWhiteSpace(
|
||||
"Slack requires fallback text for notifications and accessibility");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Markdown Escaping Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies special characters are escaped in mrkdwn.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MaliciousInput_IsEscaped()
|
||||
{
|
||||
// Arrange
|
||||
var maliciousEvent = new SlackNotificationEvent
|
||||
{
|
||||
NotificationId = "notif-xss",
|
||||
TenantId = "tenant",
|
||||
Channel = "slack",
|
||||
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 SlackRecipient
|
||||
{
|
||||
SlackChannel = "#test",
|
||||
WorkspaceId = "T123"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var slackMessage = _formatter.Format(maliciousEvent);
|
||||
|
||||
// Assert - HTML should be escaped
|
||||
var blocksJson = JsonSerializer.Serialize(slackMessage.Blocks, JsonOptions);
|
||||
blocksJson.Should().NotContain("<script>");
|
||||
blocksJson.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<SlackNotificationEvent>(eventJson, JsonOptions)!;
|
||||
|
||||
// Act
|
||||
var message1 = _formatter.Format(notificationEvent);
|
||||
var message2 = _formatter.Format(notificationEvent);
|
||||
|
||||
// Assert
|
||||
var json1 = JsonSerializer.Serialize(message1, JsonOptions);
|
||||
var json2 = JsonSerializer.Serialize(message2, JsonOptions);
|
||||
json1.Should().Be(json2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private async Task<string> LoadFixtureAsync(string filename)
|
||||
{
|
||||
var path = Path.Combine(_fixturesPath, filename);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
var testDataPath = Path.Combine(Directory.GetCurrentDirectory(), "Fixtures", "slack", filename);
|
||||
if (File.Exists(testDataPath)) path = testDataPath;
|
||||
}
|
||||
return await File.ReadAllTextAsync(path);
|
||||
}
|
||||
|
||||
private async Task<JsonNode?> LoadExpectedJsonAsync(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;
|
||||
}
|
||||
var json = await File.ReadAllTextAsync(path);
|
||||
return JsonNode.Parse(json);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Test Models
|
||||
|
||||
/// <summary>
|
||||
/// Slack notification event model.
|
||||
/// </summary>
|
||||
public sealed class SlackNotificationEvent
|
||||
{
|
||||
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 SlackRecipient Recipient { get; set; }
|
||||
public Dictionary<string, string> Metadata { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slack recipient model.
|
||||
/// </summary>
|
||||
public sealed class SlackRecipient
|
||||
{
|
||||
public required string SlackChannel { get; set; }
|
||||
public string? WorkspaceId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slack message with Block Kit.
|
||||
/// </summary>
|
||||
public sealed class SlackMessage
|
||||
{
|
||||
public required string Channel { get; set; }
|
||||
public required string Text { get; set; }
|
||||
public List<SlackBlock> Blocks { get; set; } = new();
|
||||
public string? ThreadTs { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slack Block Kit block.
|
||||
/// </summary>
|
||||
public sealed class SlackBlock
|
||||
{
|
||||
public required string Type { get; set; }
|
||||
public SlackTextObject? Text { get; set; }
|
||||
public List<SlackField>? Fields { get; set; }
|
||||
public List<SlackElement>? Elements { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slack text object.
|
||||
/// </summary>
|
||||
public sealed class SlackTextObject
|
||||
{
|
||||
public string Type { get; set; } = "mrkdwn";
|
||||
public string? Text { get; set; }
|
||||
public bool Emoji { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slack field for section blocks.
|
||||
/// </summary>
|
||||
public sealed class SlackField
|
||||
{
|
||||
public string Type { get; set; } = "mrkdwn";
|
||||
public required string Text { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slack interactive element.
|
||||
/// </summary>
|
||||
public sealed class SlackElement
|
||||
{
|
||||
public required string Type { get; set; }
|
||||
public SlackTextObject? Text { get; set; }
|
||||
public string? Url { get; set; }
|
||||
public string? ActionId { get; set; }
|
||||
public string? Style { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slack message formatter for testing.
|
||||
/// </summary>
|
||||
public sealed class SlackFormatter
|
||||
{
|
||||
public SlackMessage Format(SlackNotificationEvent evt)
|
||||
{
|
||||
var blocks = new List<SlackBlock>();
|
||||
var fallbackText = FormatFallbackText(evt);
|
||||
|
||||
switch (evt.EventType)
|
||||
{
|
||||
case "scan.completed":
|
||||
FormatScanCompleted(blocks, evt);
|
||||
break;
|
||||
case "policy.violation":
|
||||
FormatPolicyViolation(blocks, evt);
|
||||
break;
|
||||
default:
|
||||
blocks.Add(new SlackBlock
|
||||
{
|
||||
Type = "section",
|
||||
Text = new SlackTextObject { Text = $"Notification: {evt.EventType}" }
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
return new SlackMessage
|
||||
{
|
||||
Channel = evt.Recipient.SlackChannel,
|
||||
Text = fallbackText,
|
||||
Blocks = blocks
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatFallbackText(SlackNotificationEvent evt)
|
||||
{
|
||||
var imageName = evt.Payload.GetValueOrDefault("image_name")?.ToString() ?? "unknown";
|
||||
|
||||
return evt.EventType switch
|
||||
{
|
||||
"scan.completed" => FormatScanFallbackText(evt, imageName),
|
||||
"policy.violation" => $"🚨 Policy Violation - {evt.Payload.GetValueOrDefault("policy_name")} - {imageName}",
|
||||
_ => $"StellaOps Notification - {evt.EventType}"
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatScanFallbackText(SlackNotificationEvent evt, string imageName)
|
||||
{
|
||||
var verdict = evt.Payload.GetValueOrDefault("verdict")?.ToString() ?? "unknown";
|
||||
if (verdict.Equals("fail", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var criticalCount = GetVulnCount(evt, "critical");
|
||||
return $"🚨 CRITICAL - Scan Failed - {imageName} - {criticalCount} critical vulnerabilities found";
|
||||
}
|
||||
return $"✅ Scan Passed - {imageName}";
|
||||
}
|
||||
|
||||
private static void FormatScanCompleted(List<SlackBlock> blocks, SlackNotificationEvent evt)
|
||||
{
|
||||
var verdict = evt.Payload.GetValueOrDefault("verdict")?.ToString() ?? "unknown";
|
||||
var isPassing = verdict.Equals("pass", StringComparison.OrdinalIgnoreCase);
|
||||
var imageName = EscapeMrkdwn(evt.Payload.GetValueOrDefault("image_name")?.ToString() ?? "unknown");
|
||||
var digest = evt.Payload.GetValueOrDefault("image_digest")?.ToString() ?? "unknown";
|
||||
var shortDigest = digest.Length > 15 ? digest[..15] + "..." : digest;
|
||||
|
||||
// Header
|
||||
blocks.Add(new SlackBlock
|
||||
{
|
||||
Type = "header",
|
||||
Text = new SlackTextObject
|
||||
{
|
||||
Type = "plain_text",
|
||||
Text = isPassing ? "✅ Scan Passed" : "🚨 Critical Vulnerabilities Found",
|
||||
Emoji = true
|
||||
}
|
||||
});
|
||||
|
||||
// Image details
|
||||
blocks.Add(new SlackBlock
|
||||
{
|
||||
Type = "section",
|
||||
Fields = new List<SlackField>
|
||||
{
|
||||
new() { Text = $"*Image:*\n{imageName}" },
|
||||
new() { Text = $"*Digest:*\n`{shortDigest}`" }
|
||||
}
|
||||
});
|
||||
|
||||
// Vulnerability summary
|
||||
var critical = GetVulnCount(evt, "critical");
|
||||
var high = GetVulnCount(evt, "high");
|
||||
var medium = GetVulnCount(evt, "medium");
|
||||
var low = GetVulnCount(evt, "low");
|
||||
|
||||
var vulnText = isPassing
|
||||
? $"*Vulnerability Summary:*\n🔴 Critical: {critical} | 🟠 High: {high} | 🟡 Medium: {medium} | 🟢 Low: {low}"
|
||||
: $"*Vulnerability Summary:*\n🔴 Critical: *{critical}* | 🟠 High: *{high}* | 🟡 Medium: {medium} | 🟢 Low: {low}";
|
||||
|
||||
blocks.Add(new SlackBlock
|
||||
{
|
||||
Type = "section",
|
||||
Text = new SlackTextObject { Text = vulnText }
|
||||
});
|
||||
|
||||
// Critical findings (if fail)
|
||||
if (!isPassing)
|
||||
{
|
||||
blocks.Add(new SlackBlock { Type = "divider" });
|
||||
|
||||
blocks.Add(new SlackBlock
|
||||
{
|
||||
Type = "section",
|
||||
Text = new SlackTextObject { Text = "*Critical Findings:*" }
|
||||
});
|
||||
|
||||
if (evt.Payload.TryGetValue("critical_findings", out var findings) && findings is JsonElement findingsElement)
|
||||
{
|
||||
foreach (var finding in findingsElement.EnumerateArray().Take(3))
|
||||
{
|
||||
var cveId = finding.TryGetProperty("cve_id", out var cve) ? cve.GetString() : "Unknown";
|
||||
var pkg = finding.TryGetProperty("package", out var p) ? p.GetString() : "unknown";
|
||||
var version = finding.TryGetProperty("version", out var v) ? v.GetString() : "";
|
||||
var fixedVersion = finding.TryGetProperty("fixed_version", out var fv) ? fv.GetString() : "";
|
||||
var cvss = finding.TryGetProperty("cvss", out var cv) ? cv.GetDouble() : 0;
|
||||
var title = finding.TryGetProperty("title", out var t) ? t.GetString() : "";
|
||||
|
||||
blocks.Add(new SlackBlock
|
||||
{
|
||||
Type = "section",
|
||||
Text = new SlackTextObject
|
||||
{
|
||||
Text = $"🔴 *{cveId}* (CVSS {cvss})\n`{pkg}` {version} → {fixedVersion}\n{EscapeMrkdwn(title)}"
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
blocks.Add(new SlackBlock
|
||||
{
|
||||
Type = "context",
|
||||
Elements = new List<SlackElement>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Type = "mrkdwn",
|
||||
Text = new SlackTextObject { Text = "⚠️ *Action Required:* This image should not be deployed to production." }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Actions
|
||||
var findingsUrl = evt.Payload.GetValueOrDefault("findings_url")?.ToString();
|
||||
blocks.Add(new SlackBlock
|
||||
{
|
||||
Type = "actions",
|
||||
Elements = new List<SlackElement>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Type = "button",
|
||||
Text = new SlackTextObject { Type = "plain_text", Text = isPassing ? "View Details" : "View Full Report", Emoji = true },
|
||||
Url = findingsUrl,
|
||||
ActionId = "view_scan_details",
|
||||
Style = isPassing ? null : "danger"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Context with mentions
|
||||
var contextText = $"Scanned at {evt.Timestamp:yyyy-MM-ddTHH:mm:ssZ} by StellaOps";
|
||||
if (evt.Metadata.TryGetValue("mention_users", out var mentions))
|
||||
{
|
||||
if (evt.Payload.TryGetValue("metadata", out var meta) && meta is JsonElement metaElement &&
|
||||
metaElement.TryGetProperty("mention_users", out var mentionUsers))
|
||||
{
|
||||
var userMentions = string.Join(" ", mentionUsers.EnumerateArray().Select(u => $"<@{u.GetString()}>"));
|
||||
contextText = $"cc {userMentions} | {contextText}";
|
||||
}
|
||||
}
|
||||
// Check payload metadata for mentions
|
||||
if (!contextText.Contains("cc") && evt.Payload.TryGetValue("metadata", out var payloadMeta))
|
||||
{
|
||||
// Try to get mentions from nested metadata
|
||||
}
|
||||
|
||||
blocks.Add(new SlackBlock
|
||||
{
|
||||
Type = "context",
|
||||
Elements = new List<SlackElement>
|
||||
{
|
||||
new() { Type = "mrkdwn", Text = new SlackTextObject { Text = contextText } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void FormatPolicyViolation(List<SlackBlock> blocks, SlackNotificationEvent evt)
|
||||
{
|
||||
var policyName = EscapeMrkdwn(evt.Payload.GetValueOrDefault("policy_name")?.ToString() ?? "Unknown");
|
||||
var imageName = EscapeMrkdwn(evt.Payload.GetValueOrDefault("image_name")?.ToString() ?? "unknown");
|
||||
var violationType = EscapeMrkdwn(evt.Payload.GetValueOrDefault("violation_type")?.ToString() ?? "unknown");
|
||||
var severity = evt.Payload.GetValueOrDefault("severity")?.ToString() ?? "unknown";
|
||||
var details = EscapeMrkdwn(evt.Payload.GetValueOrDefault("details")?.ToString() ?? "");
|
||||
var remediation = EscapeMrkdwn(evt.Payload.GetValueOrDefault("remediation")?.ToString() ?? "");
|
||||
var policyUrl = evt.Payload.GetValueOrDefault("policy_url")?.ToString();
|
||||
|
||||
// Header
|
||||
blocks.Add(new SlackBlock
|
||||
{
|
||||
Type = "header",
|
||||
Text = new SlackTextObject { Type = "plain_text", Text = "🚨 Policy Violation Detected", Emoji = true }
|
||||
});
|
||||
|
||||
// Policy and severity
|
||||
blocks.Add(new SlackBlock
|
||||
{
|
||||
Type = "section",
|
||||
Fields = new List<SlackField>
|
||||
{
|
||||
new() { Text = $"*Policy:*\n{policyName}" },
|
||||
new() { Text = $"*Severity:*\n🔴 {char.ToUpper(severity[0]) + severity[1..]}" }
|
||||
}
|
||||
});
|
||||
|
||||
// Image and violation
|
||||
blocks.Add(new SlackBlock
|
||||
{
|
||||
Type = "section",
|
||||
Fields = new List<SlackField>
|
||||
{
|
||||
new() { Text = $"*Image:*\n{imageName}" },
|
||||
new() { Text = $"*Violation:*\n{violationType}" }
|
||||
}
|
||||
});
|
||||
|
||||
blocks.Add(new SlackBlock { Type = "divider" });
|
||||
|
||||
// Details
|
||||
if (!string.IsNullOrEmpty(details))
|
||||
{
|
||||
blocks.Add(new SlackBlock
|
||||
{
|
||||
Type = "section",
|
||||
Text = new SlackTextObject { Text = $"*Details:*\n{details}" }
|
||||
});
|
||||
}
|
||||
|
||||
// Remediation
|
||||
if (!string.IsNullOrEmpty(remediation))
|
||||
{
|
||||
blocks.Add(new SlackBlock
|
||||
{
|
||||
Type = "section",
|
||||
Text = new SlackTextObject { Text = $"*Remediation:*\n```\nUSER 1000:1000\n```\n{remediation}" }
|
||||
});
|
||||
}
|
||||
|
||||
// Actions
|
||||
if (!string.IsNullOrEmpty(policyUrl))
|
||||
{
|
||||
blocks.Add(new SlackBlock
|
||||
{
|
||||
Type = "actions",
|
||||
Elements = new List<SlackElement>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Type = "button",
|
||||
Text = new SlackTextObject { Type = "plain_text", Text = "View Policy", Emoji = true },
|
||||
Url = policyUrl,
|
||||
ActionId = "view_policy"
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Context
|
||||
blocks.Add(new SlackBlock
|
||||
{
|
||||
Type = "context",
|
||||
Elements = new List<SlackElement>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Type = "mrkdwn",
|
||||
Text = new SlackTextObject { Text = $"Detected at {evt.Timestamp:yyyy-MM-ddTHH:mm:ssZ} by StellaOps" }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static int GetVulnCount(SlackNotificationEvent evt, string severity)
|
||||
{
|
||||
if (evt.Payload.TryGetValue("vulnerabilities", out var vulns) && vulns is JsonElement vulnElement)
|
||||
{
|
||||
if (vulnElement.TryGetProperty(severity, out var count))
|
||||
return count.GetInt32();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static string EscapeMrkdwn(string? text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return "";
|
||||
return text
|
||||
.Replace("&", "&")
|
||||
.Replace("<", "<")
|
||||
.Replace(">", ">");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
Reference in New Issue
Block a user