up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-13 00:20:26 +02:00
parent e1f1bef4c1
commit 564df71bfb
2376 changed files with 334389 additions and 328032 deletions

View File

@@ -1,100 +1,100 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
using Xunit;
namespace StellaOps.Notify.Connectors.Email.Tests;
public sealed class EmailChannelHealthProviderTests
{
private static readonly EmailChannelHealthProvider Provider = new();
[Fact]
public async Task CheckAsync_ReturnsHealthy()
{
var channel = CreateChannel(enabled: true, target: "ops@example.com");
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Config.Target!,
new DateTimeOffset(2025, 10, 20, 15, 0, 0, TimeSpan.Zero),
"trace-email-001");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Healthy, result.Status);
Assert.Equal("true", result.Metadata["email.channel.enabled"]);
Assert.Equal("true", result.Metadata["email.validation.targetPresent"]);
Assert.Equal("ops@example.com", result.Metadata["email.target"]);
}
[Fact]
public async Task CheckAsync_ReturnsDegradedWhenDisabled()
{
var channel = CreateChannel(enabled: false, target: "ops@example.com");
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Config.Target!,
DateTimeOffset.UtcNow,
"trace-email-002");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Degraded, result.Status);
Assert.Equal("false", result.Metadata["email.channel.enabled"]);
}
[Fact]
public async Task CheckAsync_ReturnsUnhealthyWhenTargetMissing()
{
var channel = NotifyChannel.Create(
channelId: "channel-email-ops",
tenantId: "tenant-sec",
name: "email:ops",
type: NotifyChannelType.Email,
config: NotifyChannelConfig.Create(
secretRef: "ref://notify/channels/email/ops",
target: null,
properties: new Dictionary<string, string>
{
["smtpHost"] = "smtp.ops.example.com"
}),
enabled: true);
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Name,
DateTimeOffset.UtcNow,
"trace-email-003");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Unhealthy, result.Status);
Assert.Equal("false", result.Metadata["email.validation.targetPresent"]);
}
private static NotifyChannel CreateChannel(bool enabled, string? target)
{
return NotifyChannel.Create(
channelId: "channel-email-ops",
tenantId: "tenant-sec",
name: "email:ops",
type: NotifyChannelType.Email,
config: NotifyChannelConfig.Create(
secretRef: "ref://notify/channels/email/ops",
target: target,
properties: new Dictionary<string, string>
{
["smtpHost"] = "smtp.ops.example.com",
["password"] = "super-secret"
}),
enabled: enabled);
}
}
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
using Xunit;
namespace StellaOps.Notify.Connectors.Email.Tests;
public sealed class EmailChannelHealthProviderTests
{
private static readonly EmailChannelHealthProvider Provider = new();
[Fact]
public async Task CheckAsync_ReturnsHealthy()
{
var channel = CreateChannel(enabled: true, target: "ops@example.com");
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Config.Target!,
new DateTimeOffset(2025, 10, 20, 15, 0, 0, TimeSpan.Zero),
"trace-email-001");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Healthy, result.Status);
Assert.Equal("true", result.Metadata["email.channel.enabled"]);
Assert.Equal("true", result.Metadata["email.validation.targetPresent"]);
Assert.Equal("ops@example.com", result.Metadata["email.target"]);
}
[Fact]
public async Task CheckAsync_ReturnsDegradedWhenDisabled()
{
var channel = CreateChannel(enabled: false, target: "ops@example.com");
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Config.Target!,
DateTimeOffset.UtcNow,
"trace-email-002");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Degraded, result.Status);
Assert.Equal("false", result.Metadata["email.channel.enabled"]);
}
[Fact]
public async Task CheckAsync_ReturnsUnhealthyWhenTargetMissing()
{
var channel = NotifyChannel.Create(
channelId: "channel-email-ops",
tenantId: "tenant-sec",
name: "email:ops",
type: NotifyChannelType.Email,
config: NotifyChannelConfig.Create(
secretRef: "ref://notify/channels/email/ops",
target: null,
properties: new Dictionary<string, string>
{
["smtpHost"] = "smtp.ops.example.com"
}),
enabled: true);
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Name,
DateTimeOffset.UtcNow,
"trace-email-003");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Unhealthy, result.Status);
Assert.Equal("false", result.Metadata["email.validation.targetPresent"]);
}
private static NotifyChannel CreateChannel(bool enabled, string? target)
{
return NotifyChannel.Create(
channelId: "channel-email-ops",
tenantId: "tenant-sec",
name: "email:ops",
type: NotifyChannelType.Email,
config: NotifyChannelConfig.Create(
secretRef: "ref://notify/channels/email/ops",
target: target,
properties: new Dictionary<string, string>
{
["smtpHost"] = "smtp.ops.example.com",
["password"] = "super-secret"
}),
enabled: enabled);
}
}

View File

@@ -1,96 +1,96 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
using Xunit;
namespace StellaOps.Notify.Connectors.Slack.Tests;
public sealed class SlackChannelHealthProviderTests
{
private static readonly SlackChannelHealthProvider Provider = new();
[Fact]
public async Task CheckAsync_ReturnsHealthy()
{
var channel = CreateChannel(enabled: true, target: "#sec-ops");
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Config.Target!,
new DateTimeOffset(2025, 10, 20, 14, 0, 0, TimeSpan.Zero),
"trace-slack-001");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Healthy, result.Status);
Assert.Equal("true", result.Metadata["slack.channel.enabled"]);
Assert.Equal("true", result.Metadata["slack.validation.targetPresent"]);
Assert.Equal("#sec-ops", result.Metadata["slack.channel"]);
Assert.Equal(ComputeSecretHash(channel.Config.SecretRef), result.Metadata["slack.secretRef.hash"]);
}
[Fact]
public async Task CheckAsync_ReturnsDegradedWhenDisabled()
{
var channel = CreateChannel(enabled: false, target: "#sec-ops");
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Config.Target!,
DateTimeOffset.UtcNow,
"trace-slack-002");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Degraded, result.Status);
Assert.Equal("false", result.Metadata["slack.channel.enabled"]);
}
[Fact]
public async Task CheckAsync_ReturnsUnhealthyWhenTargetMissing()
{
var channel = CreateChannel(enabled: true, target: null);
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Name,
DateTimeOffset.UtcNow,
"trace-slack-003");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Unhealthy, result.Status);
Assert.Equal("false", result.Metadata["slack.validation.targetPresent"]);
}
private static NotifyChannel CreateChannel(bool enabled, string? target)
{
return NotifyChannel.Create(
channelId: "channel-slack-sec-ops",
tenantId: "tenant-sec",
name: "slack:sec-ops",
type: NotifyChannelType.Slack,
config: NotifyChannelConfig.Create(
secretRef: "ref://notify/channels/slack/sec-ops",
target: target,
properties: new Dictionary<string, string>
{
["workspace"] = "stellaops-sec",
["botToken"] = "xoxb-123456789012-abcdefghijklmnop"
}),
enabled: enabled);
}
private static string ComputeSecretHash(string secretRef)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(secretRef.Trim());
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
return Convert.ToHexString(hash.AsSpan(0, 8)).ToLowerInvariant();
}
}
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
using Xunit;
namespace StellaOps.Notify.Connectors.Slack.Tests;
public sealed class SlackChannelHealthProviderTests
{
private static readonly SlackChannelHealthProvider Provider = new();
[Fact]
public async Task CheckAsync_ReturnsHealthy()
{
var channel = CreateChannel(enabled: true, target: "#sec-ops");
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Config.Target!,
new DateTimeOffset(2025, 10, 20, 14, 0, 0, TimeSpan.Zero),
"trace-slack-001");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Healthy, result.Status);
Assert.Equal("true", result.Metadata["slack.channel.enabled"]);
Assert.Equal("true", result.Metadata["slack.validation.targetPresent"]);
Assert.Equal("#sec-ops", result.Metadata["slack.channel"]);
Assert.Equal(ComputeSecretHash(channel.Config.SecretRef), result.Metadata["slack.secretRef.hash"]);
}
[Fact]
public async Task CheckAsync_ReturnsDegradedWhenDisabled()
{
var channel = CreateChannel(enabled: false, target: "#sec-ops");
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Config.Target!,
DateTimeOffset.UtcNow,
"trace-slack-002");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Degraded, result.Status);
Assert.Equal("false", result.Metadata["slack.channel.enabled"]);
}
[Fact]
public async Task CheckAsync_ReturnsUnhealthyWhenTargetMissing()
{
var channel = CreateChannel(enabled: true, target: null);
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Name,
DateTimeOffset.UtcNow,
"trace-slack-003");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Unhealthy, result.Status);
Assert.Equal("false", result.Metadata["slack.validation.targetPresent"]);
}
private static NotifyChannel CreateChannel(bool enabled, string? target)
{
return NotifyChannel.Create(
channelId: "channel-slack-sec-ops",
tenantId: "tenant-sec",
name: "slack:sec-ops",
type: NotifyChannelType.Slack,
config: NotifyChannelConfig.Create(
secretRef: "ref://notify/channels/slack/sec-ops",
target: target,
properties: new Dictionary<string, string>
{
["workspace"] = "stellaops-sec",
["botToken"] = "xoxb-123456789012-abcdefghijklmnop"
}),
enabled: enabled);
}
private static string ComputeSecretHash(string secretRef)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(secretRef.Trim());
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
return Convert.ToHexString(hash.AsSpan(0, 8)).ToLowerInvariant();
}
}

View File

@@ -1,113 +1,113 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
using Xunit;
namespace StellaOps.Notify.Connectors.Slack.Tests;
public sealed class SlackChannelTestProviderTests
{
private static readonly ChannelTestPreviewRequest EmptyRequest = new(
TargetOverride: null,
TemplateId: null,
Title: null,
Summary: null,
Body: null,
TextBody: null,
Locale: null,
Metadata: new Dictionary<string, string>(),
Attachments: new List<string>());
[Fact]
public async Task BuildPreviewAsync_ProducesDeterministicMetadata()
{
var provider = new SlackChannelTestProvider();
var channel = CreateChannel(properties: new Dictionary<string, string>
{
["workspace"] = "stellaops-sec",
["botToken"] = "xoxb-123456789012-abcdefghijklmnop"
});
var context = new ChannelTestPreviewContext(
channel.TenantId,
channel,
channel.Config.Target!,
EmptyRequest,
Timestamp: new DateTimeOffset(2025, 10, 20, 12, 00, 00, TimeSpan.Zero),
TraceId: "trace-001");
var result = await provider.BuildPreviewAsync(context, CancellationToken.None);
Assert.Equal("slack", result.Preview.ChannelType.ToString().ToLowerInvariant());
Assert.Equal(channel.Config.Target, result.Preview.Target);
Assert.Equal("chat:write,chat:write.public", result.Metadata["slack.scopes.required"]);
Assert.Equal("stellaops-sec", result.Metadata["slack.config.workspace"]);
var redactedToken = result.Metadata["slack.config.botToken"];
Assert.DoesNotContain("abcdefghijklmnop", redactedToken);
Assert.StartsWith("xoxb-", redactedToken);
Assert.EndsWith("mnop", redactedToken);
using var parsed = JsonDocument.Parse(result.Preview.Body);
var contextText = parsed.RootElement
.GetProperty("blocks")[1]
.GetProperty("elements")[0]
.GetProperty("text")
.GetString();
Assert.NotNull(contextText);
Assert.Contains("trace-001", contextText);
Assert.Equal(ComputeSecretHash(channel.Config.SecretRef), result.Metadata["slack.secretRef.hash"]);
}
[Fact]
public async Task BuildPreviewAsync_RedactsSensitiveProperties()
{
var provider = new SlackChannelTestProvider();
var channel = CreateChannel(properties: new Dictionary<string, string>
{
["SigningSecret"] = "whsec_super-secret-value",
["apiToken"] = "xoxs-000000000000-super",
["endpoint"] = "https://hooks.slack.com/services/T000/B000/AAA"
});
var context = new ChannelTestPreviewContext(
channel.TenantId,
channel,
channel.Config.Target!,
EmptyRequest,
Timestamp: DateTimeOffset.UtcNow,
TraceId: "trace-002");
var result = await provider.BuildPreviewAsync(context, CancellationToken.None);
Assert.Equal("***", result.Metadata["slack.config.SigningSecret"]);
Assert.DoesNotContain("xoxs-000000000000-super", result.Metadata["slack.config.apiToken"]);
Assert.Equal("https://hooks.slack.com/services/T000/B000/AAA", result.Metadata["slack.config.endpoint"]);
}
private static NotifyChannel CreateChannel(IDictionary<string, string> properties)
{
return NotifyChannel.Create(
channelId: "channel-slack-sec-ops",
tenantId: "tenant-sec",
name: "slack:sec-ops",
type: NotifyChannelType.Slack,
config: NotifyChannelConfig.Create(
secretRef: "ref://notify/channels/slack/sec-ops",
target: "#sec-ops",
properties: properties));
}
private static string ComputeSecretHash(string secretRef)
{
using var sha = System.Security.Cryptography.SHA256.Create();
var bytes = System.Text.Encoding.UTF8.GetBytes(secretRef.Trim());
var hash = sha.ComputeHash(bytes);
return System.Convert.ToHexString(hash, 0, 8).ToLowerInvariant();
}
}
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
using Xunit;
namespace StellaOps.Notify.Connectors.Slack.Tests;
public sealed class SlackChannelTestProviderTests
{
private static readonly ChannelTestPreviewRequest EmptyRequest = new(
TargetOverride: null,
TemplateId: null,
Title: null,
Summary: null,
Body: null,
TextBody: null,
Locale: null,
Metadata: new Dictionary<string, string>(),
Attachments: new List<string>());
[Fact]
public async Task BuildPreviewAsync_ProducesDeterministicMetadata()
{
var provider = new SlackChannelTestProvider();
var channel = CreateChannel(properties: new Dictionary<string, string>
{
["workspace"] = "stellaops-sec",
["botToken"] = "xoxb-123456789012-abcdefghijklmnop"
});
var context = new ChannelTestPreviewContext(
channel.TenantId,
channel,
channel.Config.Target!,
EmptyRequest,
Timestamp: new DateTimeOffset(2025, 10, 20, 12, 00, 00, TimeSpan.Zero),
TraceId: "trace-001");
var result = await provider.BuildPreviewAsync(context, CancellationToken.None);
Assert.Equal("slack", result.Preview.ChannelType.ToString().ToLowerInvariant());
Assert.Equal(channel.Config.Target, result.Preview.Target);
Assert.Equal("chat:write,chat:write.public", result.Metadata["slack.scopes.required"]);
Assert.Equal("stellaops-sec", result.Metadata["slack.config.workspace"]);
var redactedToken = result.Metadata["slack.config.botToken"];
Assert.DoesNotContain("abcdefghijklmnop", redactedToken);
Assert.StartsWith("xoxb-", redactedToken);
Assert.EndsWith("mnop", redactedToken);
using var parsed = JsonDocument.Parse(result.Preview.Body);
var contextText = parsed.RootElement
.GetProperty("blocks")[1]
.GetProperty("elements")[0]
.GetProperty("text")
.GetString();
Assert.NotNull(contextText);
Assert.Contains("trace-001", contextText);
Assert.Equal(ComputeSecretHash(channel.Config.SecretRef), result.Metadata["slack.secretRef.hash"]);
}
[Fact]
public async Task BuildPreviewAsync_RedactsSensitiveProperties()
{
var provider = new SlackChannelTestProvider();
var channel = CreateChannel(properties: new Dictionary<string, string>
{
["SigningSecret"] = "whsec_super-secret-value",
["apiToken"] = "xoxs-000000000000-super",
["endpoint"] = "https://hooks.slack.com/services/T000/B000/AAA"
});
var context = new ChannelTestPreviewContext(
channel.TenantId,
channel,
channel.Config.Target!,
EmptyRequest,
Timestamp: DateTimeOffset.UtcNow,
TraceId: "trace-002");
var result = await provider.BuildPreviewAsync(context, CancellationToken.None);
Assert.Equal("***", result.Metadata["slack.config.SigningSecret"]);
Assert.DoesNotContain("xoxs-000000000000-super", result.Metadata["slack.config.apiToken"]);
Assert.Equal("https://hooks.slack.com/services/T000/B000/AAA", result.Metadata["slack.config.endpoint"]);
}
private static NotifyChannel CreateChannel(IDictionary<string, string> properties)
{
return NotifyChannel.Create(
channelId: "channel-slack-sec-ops",
tenantId: "tenant-sec",
name: "slack:sec-ops",
type: NotifyChannelType.Slack,
config: NotifyChannelConfig.Create(
secretRef: "ref://notify/channels/slack/sec-ops",
target: "#sec-ops",
properties: properties));
}
private static string ComputeSecretHash(string secretRef)
{
using var sha = System.Security.Cryptography.SHA256.Create();
var bytes = System.Text.Encoding.UTF8.GetBytes(secretRef.Trim());
var hash = sha.ComputeHash(bytes);
return System.Convert.ToHexString(hash, 0, 8).ToLowerInvariant();
}
}

View File

@@ -1,98 +1,98 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
using Xunit;
namespace StellaOps.Notify.Connectors.Teams.Tests;
public sealed class TeamsChannelHealthProviderTests
{
private static readonly TeamsChannelHealthProvider Provider = new();
[Fact]
public async Task CheckAsync_ReturnsHealthyWithMetadata()
{
var channel = CreateChannel(enabled: true, endpoint: "https://contoso.webhook.office.com/webhook");
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Config.Endpoint!,
new DateTimeOffset(2025, 10, 20, 12, 0, 0, TimeSpan.Zero),
"trace-health-001");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Healthy, result.Status);
Assert.Equal("Teams channel configuration validated.", result.Message);
Assert.Equal("true", result.Metadata["teams.channel.enabled"]);
Assert.Equal("true", result.Metadata["teams.validation.targetPresent"]);
Assert.Equal(channel.Config.Endpoint, result.Metadata["teams.webhook"]);
Assert.Equal(ComputeSecretHash(channel.Config.SecretRef), result.Metadata["teams.secretRef.hash"]);
}
[Fact]
public async Task CheckAsync_ReturnsDegradedWhenDisabled()
{
var channel = CreateChannel(enabled: false, endpoint: "https://contoso.webhook.office.com/webhook");
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Config.Endpoint!,
DateTimeOffset.UtcNow,
"trace-health-002");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Degraded, result.Status);
Assert.Equal("false", result.Metadata["teams.channel.enabled"]);
}
[Fact]
public async Task CheckAsync_ReturnsUnhealthyWhenTargetMissing()
{
var channel = CreateChannel(enabled: true, endpoint: null);
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Name,
DateTimeOffset.UtcNow,
"trace-health-003");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Unhealthy, result.Status);
Assert.Equal("false", result.Metadata["teams.validation.targetPresent"]);
}
private static NotifyChannel CreateChannel(bool enabled, string? endpoint)
{
return NotifyChannel.Create(
channelId: "channel-teams-sec-ops",
tenantId: "tenant-sec",
name: "teams:sec-ops",
type: NotifyChannelType.Teams,
config: NotifyChannelConfig.Create(
secretRef: "ref://notify/channels/teams/sec-ops",
target: null,
endpoint: endpoint,
properties: new Dictionary<string, string>
{
["tenant"] = "contoso.onmicrosoft.com",
["webhookKey"] = "abcdef0123456789"
}),
enabled: enabled);
}
private static string ComputeSecretHash(string secretRef)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(secretRef.Trim());
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
return Convert.ToHexString(hash.AsSpan(0, 8)).ToLowerInvariant();
}
}
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
using Xunit;
namespace StellaOps.Notify.Connectors.Teams.Tests;
public sealed class TeamsChannelHealthProviderTests
{
private static readonly TeamsChannelHealthProvider Provider = new();
[Fact]
public async Task CheckAsync_ReturnsHealthyWithMetadata()
{
var channel = CreateChannel(enabled: true, endpoint: "https://contoso.webhook.office.com/webhook");
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Config.Endpoint!,
new DateTimeOffset(2025, 10, 20, 12, 0, 0, TimeSpan.Zero),
"trace-health-001");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Healthy, result.Status);
Assert.Equal("Teams channel configuration validated.", result.Message);
Assert.Equal("true", result.Metadata["teams.channel.enabled"]);
Assert.Equal("true", result.Metadata["teams.validation.targetPresent"]);
Assert.Equal(channel.Config.Endpoint, result.Metadata["teams.webhook"]);
Assert.Equal(ComputeSecretHash(channel.Config.SecretRef), result.Metadata["teams.secretRef.hash"]);
}
[Fact]
public async Task CheckAsync_ReturnsDegradedWhenDisabled()
{
var channel = CreateChannel(enabled: false, endpoint: "https://contoso.webhook.office.com/webhook");
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Config.Endpoint!,
DateTimeOffset.UtcNow,
"trace-health-002");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Degraded, result.Status);
Assert.Equal("false", result.Metadata["teams.channel.enabled"]);
}
[Fact]
public async Task CheckAsync_ReturnsUnhealthyWhenTargetMissing()
{
var channel = CreateChannel(enabled: true, endpoint: null);
var context = new ChannelHealthContext(
channel.TenantId,
channel,
channel.Name,
DateTimeOffset.UtcNow,
"trace-health-003");
var result = await Provider.CheckAsync(context, CancellationToken.None);
Assert.Equal(ChannelHealthStatus.Unhealthy, result.Status);
Assert.Equal("false", result.Metadata["teams.validation.targetPresent"]);
}
private static NotifyChannel CreateChannel(bool enabled, string? endpoint)
{
return NotifyChannel.Create(
channelId: "channel-teams-sec-ops",
tenantId: "tenant-sec",
name: "teams:sec-ops",
type: NotifyChannelType.Teams,
config: NotifyChannelConfig.Create(
secretRef: "ref://notify/channels/teams/sec-ops",
target: null,
endpoint: endpoint,
properties: new Dictionary<string, string>
{
["tenant"] = "contoso.onmicrosoft.com",
["webhookKey"] = "abcdef0123456789"
}),
enabled: enabled);
}
private static string ComputeSecretHash(string secretRef)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(secretRef.Trim());
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
return Convert.ToHexString(hash.AsSpan(0, 8)).ToLowerInvariant();
}
}

View File

@@ -1,135 +1,135 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
using Xunit;
namespace StellaOps.Notify.Connectors.Teams.Tests;
public sealed class TeamsChannelTestProviderTests
{
[Fact]
public async Task BuildPreviewAsync_EmitsFallbackMetadata()
{
var provider = new TeamsChannelTestProvider();
var channel = CreateChannel(
endpoint: "https://contoso.webhook.office.com/webhookb2/tenant@uuid/IncomingWebhook/abcdef0123456789",
properties: new Dictionary<string, string>
{
["team"] = "secops",
["webhookKey"] = "s3cr3t-value-with-key-fragment",
["tenant"] = "contoso.onmicrosoft.com"
});
var request = new ChannelTestPreviewRequest(
TargetOverride: null,
TemplateId: null,
Title: "Notify Critical Finding",
Summary: "Critical container vulnerability detected.",
Body: "CVSS 9.8 vulnerability detected in ubuntu:22.04 base layer.",
TextBody: null,
Locale: "en-US",
Metadata: new Dictionary<string, string>(),
Attachments: new List<string>());
var context = new ChannelTestPreviewContext(
channel.TenantId,
channel,
channel.Config.Endpoint!,
request,
new DateTimeOffset(2025, 10, 20, 10, 0, 0, TimeSpan.Zero),
TraceId: "trace-teams-001");
var result = await provider.BuildPreviewAsync(context, CancellationToken.None);
Assert.Equal(NotifyChannelType.Teams, result.Preview.ChannelType);
Assert.Equal(channel.Config.Endpoint, result.Preview.Target);
Assert.Equal("Critical container vulnerability detected.", result.Preview.Summary);
Assert.NotNull(result.Metadata);
Assert.Equal(channel.Config.Endpoint, result.Metadata["teams.webhook"]);
Assert.Equal("1.5", result.Metadata["teams.card.version"]);
var fallback = result.Metadata["teams.fallbackText"];
Assert.Equal(result.Preview.TextBody, fallback);
Assert.Equal("Critical container vulnerability detected.", fallback);
Assert.Equal(ComputeSecretHash(channel.Config.SecretRef), result.Metadata["teams.secretRef.hash"]);
Assert.Equal("***", result.Metadata["teams.config.webhookKey"]);
Assert.Equal("contoso.onmicrosoft.com", result.Metadata["teams.config.tenant"]);
Assert.Equal(channel.Config.Endpoint, result.Metadata["teams.config.endpoint"]);
using var payload = JsonDocument.Parse(result.Preview.Body);
Assert.Equal("message", payload.RootElement.GetProperty("type").GetString());
Assert.Equal(result.Preview.TextBody, payload.RootElement.GetProperty("text").GetString());
Assert.Equal(result.Preview.Summary, payload.RootElement.GetProperty("summary").GetString());
var attachments = payload.RootElement.GetProperty("attachments");
Assert.True(attachments.GetArrayLength() > 0);
Assert.Equal(
"AdaptiveCard",
attachments[0].GetProperty("content").GetProperty("type").GetString());
}
[Fact]
public async Task BuildPreviewAsync_TruncatesLongFallback()
{
var provider = new TeamsChannelTestProvider();
var channel = CreateChannel(
endpoint: "https://contoso.webhook.office.com/webhookb2/tenant@uuid/IncomingWebhook/abcdef0123456789",
properties: new Dictionary<string, string>());
var longText = new string('A', 600);
var request = new ChannelTestPreviewRequest(
TargetOverride: null,
TemplateId: null,
Title: null,
Summary: null,
Body: null,
TextBody: longText,
Locale: null,
Metadata: new Dictionary<string, string>(),
Attachments: new List<string>());
var context = new ChannelTestPreviewContext(
channel.TenantId,
channel,
channel.Config.Endpoint!,
request,
DateTimeOffset.UtcNow,
TraceId: "trace-teams-002");
var result = await provider.BuildPreviewAsync(context, CancellationToken.None);
var metadata = Assert.IsAssignableFrom<IReadOnlyDictionary<string, string>>(result.Metadata);
var fallback = Assert.IsType<string>(result.Preview.TextBody);
Assert.Equal(512, fallback.Length);
Assert.Equal(fallback, metadata["teams.fallbackText"]);
Assert.StartsWith(new string('A', 512), fallback);
}
private static NotifyChannel CreateChannel(string endpoint, IDictionary<string, string> properties)
{
return NotifyChannel.Create(
channelId: "channel-teams-sec-ops",
tenantId: "tenant-sec",
name: "teams:sec-ops",
type: NotifyChannelType.Teams,
config: NotifyChannelConfig.Create(
secretRef: "ref://notify/channels/teams/sec-ops",
target: null,
endpoint: endpoint,
properties: properties));
}
private static string ComputeSecretHash(string secretRef)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(secretRef.Trim());
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
return Convert.ToHexString(hash.AsSpan(0, 8)).ToLowerInvariant();
}
}
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
using Xunit;
namespace StellaOps.Notify.Connectors.Teams.Tests;
public sealed class TeamsChannelTestProviderTests
{
[Fact]
public async Task BuildPreviewAsync_EmitsFallbackMetadata()
{
var provider = new TeamsChannelTestProvider();
var channel = CreateChannel(
endpoint: "https://contoso.webhook.office.com/webhookb2/tenant@uuid/IncomingWebhook/abcdef0123456789",
properties: new Dictionary<string, string>
{
["team"] = "secops",
["webhookKey"] = "s3cr3t-value-with-key-fragment",
["tenant"] = "contoso.onmicrosoft.com"
});
var request = new ChannelTestPreviewRequest(
TargetOverride: null,
TemplateId: null,
Title: "Notify Critical Finding",
Summary: "Critical container vulnerability detected.",
Body: "CVSS 9.8 vulnerability detected in ubuntu:22.04 base layer.",
TextBody: null,
Locale: "en-US",
Metadata: new Dictionary<string, string>(),
Attachments: new List<string>());
var context = new ChannelTestPreviewContext(
channel.TenantId,
channel,
channel.Config.Endpoint!,
request,
new DateTimeOffset(2025, 10, 20, 10, 0, 0, TimeSpan.Zero),
TraceId: "trace-teams-001");
var result = await provider.BuildPreviewAsync(context, CancellationToken.None);
Assert.Equal(NotifyChannelType.Teams, result.Preview.ChannelType);
Assert.Equal(channel.Config.Endpoint, result.Preview.Target);
Assert.Equal("Critical container vulnerability detected.", result.Preview.Summary);
Assert.NotNull(result.Metadata);
Assert.Equal(channel.Config.Endpoint, result.Metadata["teams.webhook"]);
Assert.Equal("1.5", result.Metadata["teams.card.version"]);
var fallback = result.Metadata["teams.fallbackText"];
Assert.Equal(result.Preview.TextBody, fallback);
Assert.Equal("Critical container vulnerability detected.", fallback);
Assert.Equal(ComputeSecretHash(channel.Config.SecretRef), result.Metadata["teams.secretRef.hash"]);
Assert.Equal("***", result.Metadata["teams.config.webhookKey"]);
Assert.Equal("contoso.onmicrosoft.com", result.Metadata["teams.config.tenant"]);
Assert.Equal(channel.Config.Endpoint, result.Metadata["teams.config.endpoint"]);
using var payload = JsonDocument.Parse(result.Preview.Body);
Assert.Equal("message", payload.RootElement.GetProperty("type").GetString());
Assert.Equal(result.Preview.TextBody, payload.RootElement.GetProperty("text").GetString());
Assert.Equal(result.Preview.Summary, payload.RootElement.GetProperty("summary").GetString());
var attachments = payload.RootElement.GetProperty("attachments");
Assert.True(attachments.GetArrayLength() > 0);
Assert.Equal(
"AdaptiveCard",
attachments[0].GetProperty("content").GetProperty("type").GetString());
}
[Fact]
public async Task BuildPreviewAsync_TruncatesLongFallback()
{
var provider = new TeamsChannelTestProvider();
var channel = CreateChannel(
endpoint: "https://contoso.webhook.office.com/webhookb2/tenant@uuid/IncomingWebhook/abcdef0123456789",
properties: new Dictionary<string, string>());
var longText = new string('A', 600);
var request = new ChannelTestPreviewRequest(
TargetOverride: null,
TemplateId: null,
Title: null,
Summary: null,
Body: null,
TextBody: longText,
Locale: null,
Metadata: new Dictionary<string, string>(),
Attachments: new List<string>());
var context = new ChannelTestPreviewContext(
channel.TenantId,
channel,
channel.Config.Endpoint!,
request,
DateTimeOffset.UtcNow,
TraceId: "trace-teams-002");
var result = await provider.BuildPreviewAsync(context, CancellationToken.None);
var metadata = Assert.IsAssignableFrom<IReadOnlyDictionary<string, string>>(result.Metadata);
var fallback = Assert.IsType<string>(result.Preview.TextBody);
Assert.Equal(512, fallback.Length);
Assert.Equal(fallback, metadata["teams.fallbackText"]);
Assert.StartsWith(new string('A', 512), fallback);
}
private static NotifyChannel CreateChannel(string endpoint, IDictionary<string, string> properties)
{
return NotifyChannel.Create(
channelId: "channel-teams-sec-ops",
tenantId: "tenant-sec",
name: "teams:sec-ops",
type: NotifyChannelType.Teams,
config: NotifyChannelConfig.Create(
secretRef: "ref://notify/channels/teams/sec-ops",
target: null,
endpoint: endpoint,
properties: properties));
}
private static string ComputeSecretHash(string secretRef)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(secretRef.Trim());
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
return Convert.ToHexString(hash.AsSpan(0, 8)).ToLowerInvariant();
}
}

View File

@@ -1,47 +1,47 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Xunit.Sdk;
namespace StellaOps.Notify.Models.Tests;
public sealed class DocSampleTests
{
[Theory]
[InlineData("notify-rule@1.sample.json")]
[InlineData("notify-channel@1.sample.json")]
[InlineData("notify-template@1.sample.json")]
[InlineData("notify-event@1.sample.json")]
public void CanonicalSamplesStayInSync(string fileName)
{
var json = LoadSample(fileName);
var node = JsonNode.Parse(json) ?? throw new InvalidOperationException("Sample JSON null.");
string canonical = fileName switch
{
"notify-rule@1.sample.json" => NotifyCanonicalJsonSerializer.Serialize(NotifySchemaMigration.UpgradeRule(node)),
"notify-channel@1.sample.json" => NotifyCanonicalJsonSerializer.Serialize(NotifySchemaMigration.UpgradeChannel(node)),
"notify-template@1.sample.json" => NotifyCanonicalJsonSerializer.Serialize(NotifySchemaMigration.UpgradeTemplate(node)),
"notify-event@1.sample.json" => NotifyCanonicalJsonSerializer.Serialize(NotifyCanonicalJsonSerializer.Deserialize<NotifyEvent>(json)),
_ => throw new ArgumentOutOfRangeException(nameof(fileName), fileName, "Unsupported sample.")
};
var canonicalNode = JsonNode.Parse(canonical) ?? throw new InvalidOperationException("Canonical JSON null.");
if (!JsonNode.DeepEquals(node, canonicalNode))
{
var expected = canonicalNode.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
var actual = node.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
throw new XunitException($"Sample '{fileName}' must remain canonical.\nExpected:\n{expected}\nActual:\n{actual}");
}
}
private static string LoadSample(string fileName)
{
var path = Path.Combine(AppContext.BaseDirectory, fileName);
if (!File.Exists(path))
{
throw new FileNotFoundException($"Unable to load sample '{fileName}'.", path);
}
return File.ReadAllText(path);
}
}
using System.Text.Json;
using System.Text.Json.Nodes;
using Xunit.Sdk;
namespace StellaOps.Notify.Models.Tests;
public sealed class DocSampleTests
{
[Theory]
[InlineData("notify-rule@1.sample.json")]
[InlineData("notify-channel@1.sample.json")]
[InlineData("notify-template@1.sample.json")]
[InlineData("notify-event@1.sample.json")]
public void CanonicalSamplesStayInSync(string fileName)
{
var json = LoadSample(fileName);
var node = JsonNode.Parse(json) ?? throw new InvalidOperationException("Sample JSON null.");
string canonical = fileName switch
{
"notify-rule@1.sample.json" => NotifyCanonicalJsonSerializer.Serialize(NotifySchemaMigration.UpgradeRule(node)),
"notify-channel@1.sample.json" => NotifyCanonicalJsonSerializer.Serialize(NotifySchemaMigration.UpgradeChannel(node)),
"notify-template@1.sample.json" => NotifyCanonicalJsonSerializer.Serialize(NotifySchemaMigration.UpgradeTemplate(node)),
"notify-event@1.sample.json" => NotifyCanonicalJsonSerializer.Serialize(NotifyCanonicalJsonSerializer.Deserialize<NotifyEvent>(json)),
_ => throw new ArgumentOutOfRangeException(nameof(fileName), fileName, "Unsupported sample.")
};
var canonicalNode = JsonNode.Parse(canonical) ?? throw new InvalidOperationException("Canonical JSON null.");
if (!JsonNode.DeepEquals(node, canonicalNode))
{
var expected = canonicalNode.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
var actual = node.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
throw new XunitException($"Sample '{fileName}' must remain canonical.\nExpected:\n{expected}\nActual:\n{actual}");
}
}
private static string LoadSample(string fileName)
{
var path = Path.Combine(AppContext.BaseDirectory, fileName);
if (!File.Exists(path))
{
throw new FileNotFoundException($"Unable to load sample '{fileName}'.", path);
}
return File.ReadAllText(path);
}
}

View File

@@ -1,77 +1,77 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Nodes;
namespace StellaOps.Notify.Models.Tests;
public sealed class NotifyCanonicalJsonSerializerTests
{
[Fact]
public void SerializeRuleIsDeterministic()
{
var ruleA = NotifyRule.Create(
ruleId: "rule-1",
tenantId: "tenant-a",
name: "critical",
match: NotifyRuleMatch.Create(eventKinds: new[] { NotifyEventKinds.ScannerReportReady }),
actions: new[]
{
NotifyRuleAction.Create(actionId: "b", channel: "slack:sec"),
NotifyRuleAction.Create(actionId: "a", channel: "email:soc")
},
metadata: new Dictionary<string, string>
{
["beta"] = "2",
["alpha"] = "1"
},
createdAt: DateTimeOffset.Parse("2025-10-18T00:00:00Z"),
updatedAt: DateTimeOffset.Parse("2025-10-18T00:00:00Z"));
var ruleB = NotifyRule.Create(
ruleId: "rule-1",
tenantId: "tenant-a",
name: "critical",
match: NotifyRuleMatch.Create(eventKinds: new[] { NotifyEventKinds.ScannerReportReady }),
actions: new[]
{
NotifyRuleAction.Create(actionId: "a", channel: "email:soc"),
NotifyRuleAction.Create(actionId: "b", channel: "slack:sec")
},
metadata: new Dictionary<string, string>
{
["alpha"] = "1",
["beta"] = "2"
},
createdAt: DateTimeOffset.Parse("2025-10-18T00:00:00Z"),
updatedAt: DateTimeOffset.Parse("2025-10-18T00:00:00Z"));
var jsonA = NotifyCanonicalJsonSerializer.Serialize(ruleA);
var jsonB = NotifyCanonicalJsonSerializer.Serialize(ruleB);
Assert.Equal(jsonA, jsonB);
Assert.Contains("\"schemaVersion\":\"notify.rule@1\"", jsonA, StringComparison.Ordinal);
}
[Fact]
public void SerializeEventOrdersPayloadKeys()
{
var payload = JsonNode.Parse("{\"b\":2,\"a\":1}");
var @event = NotifyEvent.Create(
eventId: Guid.NewGuid(),
kind: NotifyEventKinds.ScannerReportReady,
tenant: "tenant-a",
ts: DateTimeOffset.Parse("2025-10-18T05:41:22Z"),
payload: payload,
scope: NotifyEventScope.Create(repo: "ghcr.io/acme/api", digest: "sha256:123"));
var json = NotifyCanonicalJsonSerializer.Serialize(@event);
var payloadIndex = json.IndexOf("\"payload\":{", StringComparison.Ordinal);
Assert.NotEqual(-1, payloadIndex);
var aIndex = json.IndexOf("\"a\":1", payloadIndex, StringComparison.Ordinal);
var bIndex = json.IndexOf("\"b\":2", payloadIndex, StringComparison.Ordinal);
Assert.True(aIndex is >= 0 && bIndex is >= 0 && aIndex < bIndex, "Payload keys should be ordered alphabetically.");
}
}
using System;
using System.Collections.Generic;
using System.Text.Json.Nodes;
namespace StellaOps.Notify.Models.Tests;
public sealed class NotifyCanonicalJsonSerializerTests
{
[Fact]
public void SerializeRuleIsDeterministic()
{
var ruleA = NotifyRule.Create(
ruleId: "rule-1",
tenantId: "tenant-a",
name: "critical",
match: NotifyRuleMatch.Create(eventKinds: new[] { NotifyEventKinds.ScannerReportReady }),
actions: new[]
{
NotifyRuleAction.Create(actionId: "b", channel: "slack:sec"),
NotifyRuleAction.Create(actionId: "a", channel: "email:soc")
},
metadata: new Dictionary<string, string>
{
["beta"] = "2",
["alpha"] = "1"
},
createdAt: DateTimeOffset.Parse("2025-10-18T00:00:00Z"),
updatedAt: DateTimeOffset.Parse("2025-10-18T00:00:00Z"));
var ruleB = NotifyRule.Create(
ruleId: "rule-1",
tenantId: "tenant-a",
name: "critical",
match: NotifyRuleMatch.Create(eventKinds: new[] { NotifyEventKinds.ScannerReportReady }),
actions: new[]
{
NotifyRuleAction.Create(actionId: "a", channel: "email:soc"),
NotifyRuleAction.Create(actionId: "b", channel: "slack:sec")
},
metadata: new Dictionary<string, string>
{
["alpha"] = "1",
["beta"] = "2"
},
createdAt: DateTimeOffset.Parse("2025-10-18T00:00:00Z"),
updatedAt: DateTimeOffset.Parse("2025-10-18T00:00:00Z"));
var jsonA = NotifyCanonicalJsonSerializer.Serialize(ruleA);
var jsonB = NotifyCanonicalJsonSerializer.Serialize(ruleB);
Assert.Equal(jsonA, jsonB);
Assert.Contains("\"schemaVersion\":\"notify.rule@1\"", jsonA, StringComparison.Ordinal);
}
[Fact]
public void SerializeEventOrdersPayloadKeys()
{
var payload = JsonNode.Parse("{\"b\":2,\"a\":1}");
var @event = NotifyEvent.Create(
eventId: Guid.NewGuid(),
kind: NotifyEventKinds.ScannerReportReady,
tenant: "tenant-a",
ts: DateTimeOffset.Parse("2025-10-18T05:41:22Z"),
payload: payload,
scope: NotifyEventScope.Create(repo: "ghcr.io/acme/api", digest: "sha256:123"));
var json = NotifyCanonicalJsonSerializer.Serialize(@event);
var payloadIndex = json.IndexOf("\"payload\":{", StringComparison.Ordinal);
Assert.NotEqual(-1, payloadIndex);
var aIndex = json.IndexOf("\"a\":1", payloadIndex, StringComparison.Ordinal);
var bIndex = json.IndexOf("\"b\":2", payloadIndex, StringComparison.Ordinal);
Assert.True(aIndex is >= 0 && bIndex is >= 0 && aIndex < bIndex, "Payload keys should be ordered alphabetically.");
}
}

View File

@@ -1,46 +1,46 @@
using System;
using System.Linq;
namespace StellaOps.Notify.Models.Tests;
public sealed class NotifyDeliveryTests
{
[Fact]
public void AttemptsAreSortedChronologically()
{
var attempts = new[]
{
new NotifyDeliveryAttempt(DateTimeOffset.Parse("2025-10-19T12:25:00Z"), NotifyDeliveryAttemptStatus.Succeeded),
new NotifyDeliveryAttempt(DateTimeOffset.Parse("2025-10-19T12:15:00Z"), NotifyDeliveryAttemptStatus.Sending),
};
var delivery = NotifyDelivery.Create(
deliveryId: "delivery-1",
tenantId: "tenant-a",
ruleId: "rule-1",
actionId: "action-1",
eventId: Guid.NewGuid(),
kind: NotifyEventKinds.ScannerReportReady,
status: NotifyDeliveryStatus.Sent,
attempts: attempts);
Assert.Collection(
delivery.Attempts,
attempt => Assert.Equal(NotifyDeliveryAttemptStatus.Sending, attempt.Status),
attempt => Assert.Equal(NotifyDeliveryAttemptStatus.Succeeded, attempt.Status));
}
[Fact]
public void RenderedNormalizesAttachments()
{
var rendered = NotifyDeliveryRendered.Create(
channelType: NotifyChannelType.Slack,
format: NotifyDeliveryFormat.Slack,
target: "#sec",
title: "Alert",
body: "Body",
attachments: new[] { "B", "a", "a" });
Assert.Equal(new[] { "B", "a" }.OrderBy(x => x, StringComparer.Ordinal), rendered.Attachments);
}
}
using System;
using System.Linq;
namespace StellaOps.Notify.Models.Tests;
public sealed class NotifyDeliveryTests
{
[Fact]
public void AttemptsAreSortedChronologically()
{
var attempts = new[]
{
new NotifyDeliveryAttempt(DateTimeOffset.Parse("2025-10-19T12:25:00Z"), NotifyDeliveryAttemptStatus.Succeeded),
new NotifyDeliveryAttempt(DateTimeOffset.Parse("2025-10-19T12:15:00Z"), NotifyDeliveryAttemptStatus.Sending),
};
var delivery = NotifyDelivery.Create(
deliveryId: "delivery-1",
tenantId: "tenant-a",
ruleId: "rule-1",
actionId: "action-1",
eventId: Guid.NewGuid(),
kind: NotifyEventKinds.ScannerReportReady,
status: NotifyDeliveryStatus.Sent,
attempts: attempts);
Assert.Collection(
delivery.Attempts,
attempt => Assert.Equal(NotifyDeliveryAttemptStatus.Sending, attempt.Status),
attempt => Assert.Equal(NotifyDeliveryAttemptStatus.Succeeded, attempt.Status));
}
[Fact]
public void RenderedNormalizesAttachments()
{
var rendered = NotifyDeliveryRendered.Create(
channelType: NotifyChannelType.Slack,
format: NotifyDeliveryFormat.Slack,
target: "#sec",
title: "Alert",
body: "Body",
attachments: new[] { "B", "a", "a" });
Assert.Equal(new[] { "B", "a" }.OrderBy(x => x, StringComparer.Ordinal), rendered.Attachments);
}
}

View File

@@ -1,63 +1,63 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.Notify.Models.Tests;
public sealed class NotifyRuleTests
{
[Fact]
public void ConstructorThrowsWhenActionsMissing()
{
var match = NotifyRuleMatch.Create(eventKinds: new[] { NotifyEventKinds.ScannerReportReady });
var exception = Assert.Throws<ArgumentException>(() =>
NotifyRule.Create(
ruleId: "rule-1",
tenantId: "tenant-a",
name: "critical",
match: match,
actions: Array.Empty<NotifyRuleAction>()));
Assert.Contains("At least one action is required", exception.Message, StringComparison.Ordinal);
}
[Fact]
public void ConstructorNormalizesCollections()
{
var rule = NotifyRule.Create(
ruleId: "rule-1",
tenantId: "tenant-a",
name: "critical",
match: NotifyRuleMatch.Create(
eventKinds: new[] { "Zastava.Admission", NotifyEventKinds.ScannerReportReady }),
actions: new[]
{
NotifyRuleAction.Create(actionId: "b", channel: "slack:sec-alerts", throttle: TimeSpan.FromMinutes(5)),
NotifyRuleAction.Create(actionId: "a", channel: "email:soc", metadata: new Dictionary<string, string>
{
[" locale "] = " EN-us "
})
},
labels: new Dictionary<string, string>
{
[" team "] = " SecOps "
},
metadata: new Dictionary<string, string>
{
["source"] = "tests"
});
Assert.Equal(NotifySchemaVersions.Rule, rule.SchemaVersion);
Assert.Equal(new[] { "scanner.report.ready", "zastava.admission" }, rule.Match.EventKinds);
Assert.Equal(new[] { "a", "b" }, rule.Actions.Select(action => action.ActionId));
Assert.Equal(TimeSpan.FromMinutes(5), rule.Actions.Last().Throttle);
Assert.Equal("secops", rule.Labels.Single().Value.ToLowerInvariant());
Assert.Equal("en-us", rule.Actions.First().Metadata["locale"].ToLowerInvariant());
var json = NotifyCanonicalJsonSerializer.Serialize(rule);
Assert.Contains("\"schemaVersion\":\"notify.rule@1\"", json, StringComparison.Ordinal);
Assert.Contains("\"actions\":[{\"actionId\":\"a\"", json, StringComparison.Ordinal);
Assert.Contains("\"throttle\":\"PT5M\"", json, StringComparison.Ordinal);
}
}
using System;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.Notify.Models.Tests;
public sealed class NotifyRuleTests
{
[Fact]
public void ConstructorThrowsWhenActionsMissing()
{
var match = NotifyRuleMatch.Create(eventKinds: new[] { NotifyEventKinds.ScannerReportReady });
var exception = Assert.Throws<ArgumentException>(() =>
NotifyRule.Create(
ruleId: "rule-1",
tenantId: "tenant-a",
name: "critical",
match: match,
actions: Array.Empty<NotifyRuleAction>()));
Assert.Contains("At least one action is required", exception.Message, StringComparison.Ordinal);
}
[Fact]
public void ConstructorNormalizesCollections()
{
var rule = NotifyRule.Create(
ruleId: "rule-1",
tenantId: "tenant-a",
name: "critical",
match: NotifyRuleMatch.Create(
eventKinds: new[] { "Zastava.Admission", NotifyEventKinds.ScannerReportReady }),
actions: new[]
{
NotifyRuleAction.Create(actionId: "b", channel: "slack:sec-alerts", throttle: TimeSpan.FromMinutes(5)),
NotifyRuleAction.Create(actionId: "a", channel: "email:soc", metadata: new Dictionary<string, string>
{
[" locale "] = " EN-us "
})
},
labels: new Dictionary<string, string>
{
[" team "] = " SecOps "
},
metadata: new Dictionary<string, string>
{
["source"] = "tests"
});
Assert.Equal(NotifySchemaVersions.Rule, rule.SchemaVersion);
Assert.Equal(new[] { "scanner.report.ready", "zastava.admission" }, rule.Match.EventKinds);
Assert.Equal(new[] { "a", "b" }, rule.Actions.Select(action => action.ActionId));
Assert.Equal(TimeSpan.FromMinutes(5), rule.Actions.Last().Throttle);
Assert.Equal("secops", rule.Labels.Single().Value.ToLowerInvariant());
Assert.Equal("en-us", rule.Actions.First().Metadata["locale"].ToLowerInvariant());
var json = NotifyCanonicalJsonSerializer.Serialize(rule);
Assert.Contains("\"schemaVersion\":\"notify.rule@1\"", json, StringComparison.Ordinal);
Assert.Contains("\"actions\":[{\"actionId\":\"a\"", json, StringComparison.Ordinal);
Assert.Contains("\"throttle\":\"PT5M\"", json, StringComparison.Ordinal);
}
}

View File

@@ -1,101 +1,101 @@
using System;
using System.Text.Json.Nodes;
namespace StellaOps.Notify.Models.Tests;
public sealed class NotifySchemaMigrationTests
{
[Fact]
public void UpgradeRuleAddsSchemaVersionWhenMissing()
{
var json = JsonNode.Parse(
"""
{
"ruleId": "rule-legacy",
"tenantId": "tenant-1",
"name": "legacy",
"enabled": true,
"match": { "eventKinds": ["scanner.report.ready"] },
"actions": [ { "actionId": "send", "channel": "email:legacy", "enabled": true } ],
"createdAt": "2025-10-18T00:00:00Z",
"updatedAt": "2025-10-18T00:00:00Z"
}
""")!;
var rule = NotifySchemaMigration.UpgradeRule(json);
Assert.Equal(NotifySchemaVersions.Rule, rule.SchemaVersion);
Assert.Equal("rule-legacy", rule.RuleId);
}
[Fact]
public void UpgradeRuleThrowsOnUnknownSchema()
{
var json = JsonNode.Parse(
"""
{
"schemaVersion": "notify.rule@2",
"ruleId": "rule-future",
"tenantId": "tenant-1",
"name": "future",
"enabled": true,
"match": { "eventKinds": ["scanner.report.ready"] },
"actions": [ { "actionId": "send", "channel": "email:soc", "enabled": true } ],
"createdAt": "2025-10-18T00:00:00Z",
"updatedAt": "2025-10-18T00:00:00Z"
}
""")!;
var exception = Assert.Throws<NotSupportedException>(() => NotifySchemaMigration.UpgradeRule(json));
Assert.Contains("notify rule schema version", exception.Message, StringComparison.Ordinal);
}
[Fact]
public void UpgradeChannelDefaultsMissingVersion()
{
var json = JsonNode.Parse(
"""
{
"channelId": "channel-email",
"tenantId": "tenant-1",
"name": "email:soc",
"type": "email",
"config": { "secretRef": "ref://notify/channels/email/soc" },
"enabled": true,
"createdAt": "2025-10-18T00:00:00Z",
"updatedAt": "2025-10-18T00:00:00Z"
}
""")!;
var channel = NotifySchemaMigration.UpgradeChannel(json);
Assert.Equal(NotifySchemaVersions.Channel, channel.SchemaVersion);
Assert.Equal("channel-email", channel.ChannelId);
}
[Fact]
public void UpgradeTemplateDefaultsMissingVersion()
{
var json = JsonNode.Parse(
"""
{
"templateId": "tmpl-slack-concise",
"tenantId": "tenant-1",
"channelType": "slack",
"key": "concise",
"locale": "en-us",
"body": "{{summary}}",
"renderMode": "markdown",
"format": "slack",
"createdAt": "2025-10-18T00:00:00Z",
"updatedAt": "2025-10-18T00:00:00Z"
}
""")!;
var template = NotifySchemaMigration.UpgradeTemplate(json);
Assert.Equal(NotifySchemaVersions.Template, template.SchemaVersion);
Assert.Equal("tmpl-slack-concise", template.TemplateId);
}
}
using System;
using System.Text.Json.Nodes;
namespace StellaOps.Notify.Models.Tests;
public sealed class NotifySchemaMigrationTests
{
[Fact]
public void UpgradeRuleAddsSchemaVersionWhenMissing()
{
var json = JsonNode.Parse(
"""
{
"ruleId": "rule-legacy",
"tenantId": "tenant-1",
"name": "legacy",
"enabled": true,
"match": { "eventKinds": ["scanner.report.ready"] },
"actions": [ { "actionId": "send", "channel": "email:legacy", "enabled": true } ],
"createdAt": "2025-10-18T00:00:00Z",
"updatedAt": "2025-10-18T00:00:00Z"
}
""")!;
var rule = NotifySchemaMigration.UpgradeRule(json);
Assert.Equal(NotifySchemaVersions.Rule, rule.SchemaVersion);
Assert.Equal("rule-legacy", rule.RuleId);
}
[Fact]
public void UpgradeRuleThrowsOnUnknownSchema()
{
var json = JsonNode.Parse(
"""
{
"schemaVersion": "notify.rule@2",
"ruleId": "rule-future",
"tenantId": "tenant-1",
"name": "future",
"enabled": true,
"match": { "eventKinds": ["scanner.report.ready"] },
"actions": [ { "actionId": "send", "channel": "email:soc", "enabled": true } ],
"createdAt": "2025-10-18T00:00:00Z",
"updatedAt": "2025-10-18T00:00:00Z"
}
""")!;
var exception = Assert.Throws<NotSupportedException>(() => NotifySchemaMigration.UpgradeRule(json));
Assert.Contains("notify rule schema version", exception.Message, StringComparison.Ordinal);
}
[Fact]
public void UpgradeChannelDefaultsMissingVersion()
{
var json = JsonNode.Parse(
"""
{
"channelId": "channel-email",
"tenantId": "tenant-1",
"name": "email:soc",
"type": "email",
"config": { "secretRef": "ref://notify/channels/email/soc" },
"enabled": true,
"createdAt": "2025-10-18T00:00:00Z",
"updatedAt": "2025-10-18T00:00:00Z"
}
""")!;
var channel = NotifySchemaMigration.UpgradeChannel(json);
Assert.Equal(NotifySchemaVersions.Channel, channel.SchemaVersion);
Assert.Equal("channel-email", channel.ChannelId);
}
[Fact]
public void UpgradeTemplateDefaultsMissingVersion()
{
var json = JsonNode.Parse(
"""
{
"templateId": "tmpl-slack-concise",
"tenantId": "tenant-1",
"channelType": "slack",
"key": "concise",
"locale": "en-us",
"body": "{{summary}}",
"renderMode": "markdown",
"format": "slack",
"createdAt": "2025-10-18T00:00:00Z",
"updatedAt": "2025-10-18T00:00:00Z"
}
""")!;
var template = NotifySchemaMigration.UpgradeTemplate(json);
Assert.Equal(NotifySchemaVersions.Template, template.SchemaVersion);
Assert.Equal("tmpl-slack-concise", template.TemplateId);
}
}

View File

@@ -1,17 +1,17 @@
using System;
using System.IO;
using System.Text.Json;
using System.Text.Json.Nodes;
using StellaOps.Notify.Models;
using Xunit.Sdk;
namespace StellaOps.Notify.Models.Tests;
public sealed class PlatformEventSamplesTests
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
[Theory]
using System;
using System.IO;
using System.Text.Json;
using System.Text.Json.Nodes;
using StellaOps.Notify.Models;
using Xunit.Sdk;
namespace StellaOps.Notify.Models.Tests;
public sealed class PlatformEventSamplesTests
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
[Theory]
[InlineData("scanner.report.ready@1.sample.json", NotifyEventKinds.ScannerReportReady)]
[InlineData("scanner.scan.completed@1.sample.json", NotifyEventKinds.ScannerScanCompleted)]
[InlineData("scheduler.rescan.delta@1.sample.json", NotifyEventKinds.SchedulerRescanDelta)]
@@ -20,36 +20,36 @@ public sealed class PlatformEventSamplesTests
[InlineData("airgap-bundle-import@1.sample.json", NotifyEventKinds.AirgapBundleImport)]
[InlineData("airgap-portable-export-completed@1.sample.json", NotifyEventKinds.AirgapPortableExportCompleted)]
public void PlatformEventSamplesRoundtripThroughNotifySerializer(string fileName, string expectedKind)
{
var json = LoadSample(fileName);
var notifyEvent = JsonSerializer.Deserialize<NotifyEvent>(json, SerializerOptions);
Assert.NotNull(notifyEvent);
Assert.Equal(expectedKind, notifyEvent!.Kind);
Assert.NotEqual(Guid.Empty, notifyEvent.EventId);
Assert.False(string.IsNullOrWhiteSpace(notifyEvent.Tenant));
Assert.Equal(TimeSpan.Zero, notifyEvent.Ts.Offset);
var canonicalJson = NotifyCanonicalJsonSerializer.Serialize(notifyEvent);
var canonicalNode = JsonNode.Parse(canonicalJson) ?? throw new InvalidOperationException("Canonical JSON null.");
var sampleNode = JsonNode.Parse(json) ?? throw new InvalidOperationException("Sample JSON null.");
if (!JsonNode.DeepEquals(sampleNode, canonicalNode))
{
var expected = canonicalNode.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
var actual = sampleNode.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
throw new Xunit.Sdk.XunitException($"Sample '{fileName}' must remain canonical.\nExpected:\n{expected}\nActual:\n{actual}");
}
}
private static string LoadSample(string fileName)
{
var path = Path.Combine(AppContext.BaseDirectory, fileName);
if (!File.Exists(path))
{
throw new FileNotFoundException($"Unable to locate sample '{fileName}'.", path);
}
return File.ReadAllText(path);
}
}
{
var json = LoadSample(fileName);
var notifyEvent = JsonSerializer.Deserialize<NotifyEvent>(json, SerializerOptions);
Assert.NotNull(notifyEvent);
Assert.Equal(expectedKind, notifyEvent!.Kind);
Assert.NotEqual(Guid.Empty, notifyEvent.EventId);
Assert.False(string.IsNullOrWhiteSpace(notifyEvent.Tenant));
Assert.Equal(TimeSpan.Zero, notifyEvent.Ts.Offset);
var canonicalJson = NotifyCanonicalJsonSerializer.Serialize(notifyEvent);
var canonicalNode = JsonNode.Parse(canonicalJson) ?? throw new InvalidOperationException("Canonical JSON null.");
var sampleNode = JsonNode.Parse(json) ?? throw new InvalidOperationException("Sample JSON null.");
if (!JsonNode.DeepEquals(sampleNode, canonicalNode))
{
var expected = canonicalNode.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
var actual = sampleNode.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
throw new Xunit.Sdk.XunitException($"Sample '{fileName}' must remain canonical.\nExpected:\n{expected}\nActual:\n{actual}");
}
}
private static string LoadSample(string fileName)
{
var path = Path.Combine(AppContext.BaseDirectory, fileName);
if (!File.Exists(path))
{
throw new FileNotFoundException($"Unable to locate sample '{fileName}'.", path);
}
return File.ReadAllText(path);
}
}

View File

@@ -1,223 +1,223 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using DotNet.Testcontainers.Configurations;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Client.JetStream;
using NATS.Client.JetStream.Models;
using StellaOps.Notify.Models;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Queue.Nats;
using Xunit;
namespace StellaOps.Notify.Queue.Tests;
public sealed class NatsNotifyDeliveryQueueTests : IAsyncLifetime
{
private readonly TestcontainersContainer _nats;
private string? _skipReason;
public NatsNotifyDeliveryQueueTests()
{
_nats = new TestcontainersBuilder<TestcontainersContainer>()
.WithImage("nats:2.10-alpine")
.WithCleanUp(true)
.WithName($"nats-notify-delivery-{Guid.NewGuid():N}")
.WithPortBinding(4222, true)
.WithCommand("--jetstream")
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(4222))
.Build();
}
public async Task InitializeAsync()
{
try
{
await _nats.StartAsync();
}
catch (Exception ex)
{
_skipReason = $"NATS-backed delivery tests skipped: {ex.Message}";
}
}
public async Task DisposeAsync()
{
if (_skipReason is not null)
{
return;
}
await _nats.DisposeAsync().ConfigureAwait(false);
}
[Fact]
public async Task Publish_ShouldDeduplicate_ByDeliveryId()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var delivery = TestData.CreateDelivery("tenant-a");
var message = new NotifyDeliveryQueueMessage(
delivery,
channelId: "chan-a",
channelType: NotifyChannelType.Slack);
var first = await queue.PublishAsync(message);
first.Deduplicated.Should().BeFalse();
var second = await queue.PublishAsync(message);
second.Deduplicated.Should().BeTrue();
second.MessageId.Should().Be(first.MessageId);
}
[Fact]
public async Task Release_Retry_ShouldReschedule()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
await queue.PublishAsync(new NotifyDeliveryQueueMessage(
TestData.CreateDelivery(),
channelId: "chan-retry",
channelType: NotifyChannelType.Teams));
var lease = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(2)))).Single();
await lease.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
var retried = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(2)))).Single();
retried.Attempt.Should().BeGreaterThan(lease.Attempt);
await retried.AcknowledgeAsync();
}
[Fact]
public async Task Release_RetryBeyondMax_ShouldDeadLetter()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions(static opts =>
{
opts.MaxDeliveryAttempts = 2;
opts.Nats.DeadLetterStream = "NOTIFY_DELIVERY_DEAD_TEST";
opts.Nats.DeadLetterSubject = "notify.delivery.dead.test";
});
await using var queue = CreateQueue(options);
await queue.PublishAsync(new NotifyDeliveryQueueMessage(
TestData.CreateDelivery(),
channelId: "chan-dead",
channelType: NotifyChannelType.Webhook));
var lease = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(2)))).Single();
await lease.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
var second = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(2)))).Single();
await second.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
await Task.Delay(200);
await using var connection = new NatsConnection(new NatsOpts { Url = options.Nats.Url! });
await connection.ConnectAsync();
var js = new NatsJSContext(connection);
var consumerConfig = new ConsumerConfig
{
DurableName = "notify-delivery-dead-test",
DeliverPolicy = ConsumerConfigDeliverPolicy.All,
AckPolicy = ConsumerConfigAckPolicy.Explicit
};
var consumer = await js.CreateConsumerAsync(options.Nats.DeadLetterStream, consumerConfig);
var fetchOpts = new NatsJSFetchOpts { MaxMsgs = 1, Expires = TimeSpan.FromSeconds(1) };
NatsJSMsg<byte[]>? dlqMsg = null;
await foreach (var msg in consumer.FetchAsync(NatsRawSerializer<byte[]>.Default, fetchOpts))
{
dlqMsg = msg;
await msg.AckAsync(new AckOpts());
break;
}
dlqMsg.Should().NotBeNull();
}
private NatsNotifyDeliveryQueue CreateQueue(NotifyDeliveryQueueOptions options)
{
return new NatsNotifyDeliveryQueue(
options,
options.Nats,
NullLogger<NatsNotifyDeliveryQueue>.Instance,
TimeProvider.System);
}
private NotifyDeliveryQueueOptions CreateOptions(Action<NotifyDeliveryQueueOptions>? configure = null)
{
var url = $"nats://{_nats.Hostname}:{_nats.GetMappedPublicPort(4222)}";
var opts = new NotifyDeliveryQueueOptions
{
Transport = NotifyQueueTransportKind.Nats,
DefaultLeaseDuration = TimeSpan.FromSeconds(2),
MaxDeliveryAttempts = 3,
RetryInitialBackoff = TimeSpan.FromMilliseconds(20),
RetryMaxBackoff = TimeSpan.FromMilliseconds(200),
Nats = new NotifyNatsDeliveryQueueOptions
{
Url = url,
Stream = "NOTIFY_DELIVERY_TEST",
Subject = "notify.delivery.test",
DeadLetterStream = "NOTIFY_DELIVERY_TEST_DEAD",
DeadLetterSubject = "notify.delivery.test.dead",
DurableConsumer = "notify-delivery-tests",
MaxAckPending = 32,
AckWait = TimeSpan.FromSeconds(2),
RetryDelay = TimeSpan.FromMilliseconds(100),
IdleHeartbeat = TimeSpan.FromMilliseconds(200)
}
};
configure?.Invoke(opts);
return opts;
}
private bool SkipIfUnavailable()
=> _skipReason is not null;
private static class TestData
{
public static NotifyDelivery CreateDelivery(string tenantId = "tenant-1")
{
return NotifyDelivery.Create(
deliveryId: Guid.NewGuid().ToString("n"),
tenantId: tenantId,
ruleId: "rule-1",
actionId: "action-1",
eventId: Guid.NewGuid(),
kind: "scanner.report.ready",
status: NotifyDeliveryStatus.Pending,
createdAt: DateTimeOffset.UtcNow);
}
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using DotNet.Testcontainers.Configurations;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Client.JetStream;
using NATS.Client.JetStream.Models;
using StellaOps.Notify.Models;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Queue.Nats;
using Xunit;
namespace StellaOps.Notify.Queue.Tests;
public sealed class NatsNotifyDeliveryQueueTests : IAsyncLifetime
{
private readonly TestcontainersContainer _nats;
private string? _skipReason;
public NatsNotifyDeliveryQueueTests()
{
_nats = new TestcontainersBuilder<TestcontainersContainer>()
.WithImage("nats:2.10-alpine")
.WithCleanUp(true)
.WithName($"nats-notify-delivery-{Guid.NewGuid():N}")
.WithPortBinding(4222, true)
.WithCommand("--jetstream")
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(4222))
.Build();
}
public async Task InitializeAsync()
{
try
{
await _nats.StartAsync();
}
catch (Exception ex)
{
_skipReason = $"NATS-backed delivery tests skipped: {ex.Message}";
}
}
public async Task DisposeAsync()
{
if (_skipReason is not null)
{
return;
}
await _nats.DisposeAsync().ConfigureAwait(false);
}
[Fact]
public async Task Publish_ShouldDeduplicate_ByDeliveryId()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var delivery = TestData.CreateDelivery("tenant-a");
var message = new NotifyDeliveryQueueMessage(
delivery,
channelId: "chan-a",
channelType: NotifyChannelType.Slack);
var first = await queue.PublishAsync(message);
first.Deduplicated.Should().BeFalse();
var second = await queue.PublishAsync(message);
second.Deduplicated.Should().BeTrue();
second.MessageId.Should().Be(first.MessageId);
}
[Fact]
public async Task Release_Retry_ShouldReschedule()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
await queue.PublishAsync(new NotifyDeliveryQueueMessage(
TestData.CreateDelivery(),
channelId: "chan-retry",
channelType: NotifyChannelType.Teams));
var lease = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(2)))).Single();
await lease.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
var retried = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(2)))).Single();
retried.Attempt.Should().BeGreaterThan(lease.Attempt);
await retried.AcknowledgeAsync();
}
[Fact]
public async Task Release_RetryBeyondMax_ShouldDeadLetter()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions(static opts =>
{
opts.MaxDeliveryAttempts = 2;
opts.Nats.DeadLetterStream = "NOTIFY_DELIVERY_DEAD_TEST";
opts.Nats.DeadLetterSubject = "notify.delivery.dead.test";
});
await using var queue = CreateQueue(options);
await queue.PublishAsync(new NotifyDeliveryQueueMessage(
TestData.CreateDelivery(),
channelId: "chan-dead",
channelType: NotifyChannelType.Webhook));
var lease = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(2)))).Single();
await lease.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
var second = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(2)))).Single();
await second.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
await Task.Delay(200);
await using var connection = new NatsConnection(new NatsOpts { Url = options.Nats.Url! });
await connection.ConnectAsync();
var js = new NatsJSContext(connection);
var consumerConfig = new ConsumerConfig
{
DurableName = "notify-delivery-dead-test",
DeliverPolicy = ConsumerConfigDeliverPolicy.All,
AckPolicy = ConsumerConfigAckPolicy.Explicit
};
var consumer = await js.CreateConsumerAsync(options.Nats.DeadLetterStream, consumerConfig);
var fetchOpts = new NatsJSFetchOpts { MaxMsgs = 1, Expires = TimeSpan.FromSeconds(1) };
NatsJSMsg<byte[]>? dlqMsg = null;
await foreach (var msg in consumer.FetchAsync(NatsRawSerializer<byte[]>.Default, fetchOpts))
{
dlqMsg = msg;
await msg.AckAsync(new AckOpts());
break;
}
dlqMsg.Should().NotBeNull();
}
private NatsNotifyDeliveryQueue CreateQueue(NotifyDeliveryQueueOptions options)
{
return new NatsNotifyDeliveryQueue(
options,
options.Nats,
NullLogger<NatsNotifyDeliveryQueue>.Instance,
TimeProvider.System);
}
private NotifyDeliveryQueueOptions CreateOptions(Action<NotifyDeliveryQueueOptions>? configure = null)
{
var url = $"nats://{_nats.Hostname}:{_nats.GetMappedPublicPort(4222)}";
var opts = new NotifyDeliveryQueueOptions
{
Transport = NotifyQueueTransportKind.Nats,
DefaultLeaseDuration = TimeSpan.FromSeconds(2),
MaxDeliveryAttempts = 3,
RetryInitialBackoff = TimeSpan.FromMilliseconds(20),
RetryMaxBackoff = TimeSpan.FromMilliseconds(200),
Nats = new NotifyNatsDeliveryQueueOptions
{
Url = url,
Stream = "NOTIFY_DELIVERY_TEST",
Subject = "notify.delivery.test",
DeadLetterStream = "NOTIFY_DELIVERY_TEST_DEAD",
DeadLetterSubject = "notify.delivery.test.dead",
DurableConsumer = "notify-delivery-tests",
MaxAckPending = 32,
AckWait = TimeSpan.FromSeconds(2),
RetryDelay = TimeSpan.FromMilliseconds(100),
IdleHeartbeat = TimeSpan.FromMilliseconds(200)
}
};
configure?.Invoke(opts);
return opts;
}
private bool SkipIfUnavailable()
=> _skipReason is not null;
private static class TestData
{
public static NotifyDelivery CreateDelivery(string tenantId = "tenant-1")
{
return NotifyDelivery.Create(
deliveryId: Guid.NewGuid().ToString("n"),
tenantId: tenantId,
ruleId: "rule-1",
actionId: "action-1",
eventId: Guid.NewGuid(),
kind: "scanner.report.ready",
status: NotifyDeliveryStatus.Pending,
createdAt: DateTimeOffset.UtcNow);
}
}
}

View File

@@ -1,225 +1,225 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using DotNet.Testcontainers.Configurations;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Notify.Models;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Queue.Nats;
using Xunit;
namespace StellaOps.Notify.Queue.Tests;
public sealed class NatsNotifyEventQueueTests : IAsyncLifetime
{
private readonly TestcontainersContainer _nats;
private string? _skipReason;
public NatsNotifyEventQueueTests()
{
_nats = new TestcontainersBuilder<TestcontainersContainer>()
.WithImage("nats:2.10-alpine")
.WithCleanUp(true)
.WithName($"nats-notify-tests-{Guid.NewGuid():N}")
.WithPortBinding(4222, true)
.WithCommand("--jetstream")
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(4222))
.Build();
}
public async Task InitializeAsync()
{
try
{
await _nats.StartAsync();
}
catch (Exception ex)
{
_skipReason = $"NATS-backed tests skipped: {ex.Message}";
}
}
public async Task DisposeAsync()
{
if (_skipReason is not null)
{
return;
}
await _nats.DisposeAsync().ConfigureAwait(false);
}
[Fact]
public async Task Publish_ShouldDeduplicate_ByIdempotencyKey()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var notifyEvent = TestData.CreateEvent("tenant-a");
var message = new NotifyQueueEventMessage(
notifyEvent,
options.Nats.Subject,
traceId: "trace-1");
var first = await queue.PublishAsync(message);
first.Deduplicated.Should().BeFalse();
var second = await queue.PublishAsync(message);
second.Deduplicated.Should().BeTrue();
second.MessageId.Should().Be(first.MessageId);
}
[Fact]
public async Task Lease_Acknowledge_ShouldRemoveMessage()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var notifyEvent = TestData.CreateEvent("tenant-b");
var message = new NotifyQueueEventMessage(
notifyEvent,
options.Nats.Subject,
traceId: "trace-xyz",
attributes: new Dictionary<string, string> { { "source", "scanner" } });
await queue.PublishAsync(message);
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(2)));
leases.Should().ContainSingle();
var lease = leases[0];
lease.Attempt.Should().BeGreaterThanOrEqualTo(1);
lease.Message.Event.EventId.Should().Be(notifyEvent.EventId);
lease.TraceId.Should().Be("trace-xyz");
lease.Attributes.Should().ContainKey("source").WhoseValue.Should().Be("scanner");
await lease.AcknowledgeAsync();
var afterAck = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(1)));
afterAck.Should().BeEmpty();
}
[Fact]
public async Task Lease_ShouldPreserveOrdering()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var first = TestData.CreateEvent();
var second = TestData.CreateEvent();
await queue.PublishAsync(new NotifyQueueEventMessage(first, options.Nats.Subject));
await queue.PublishAsync(new NotifyQueueEventMessage(second, options.Nats.Subject));
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-order", 2, TimeSpan.FromSeconds(2)));
leases.Should().HaveCount(2);
leases.Select(x => x.Message.Event.EventId)
.Should()
.ContainInOrder(first.EventId, second.EventId);
}
[Fact]
public async Task ClaimExpired_ShouldReassignLease()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var notifyEvent = TestData.CreateEvent();
await queue.PublishAsync(new NotifyQueueEventMessage(notifyEvent, options.Nats.Subject));
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-initial", 1, TimeSpan.FromMilliseconds(500)));
leases.Should().ContainSingle();
await Task.Delay(200);
var claimed = await queue.ClaimExpiredAsync(new NotifyQueueClaimOptions("worker-reclaim", 1, TimeSpan.FromMilliseconds(100)));
claimed.Should().ContainSingle();
var lease = claimed[0];
lease.Consumer.Should().Be("worker-reclaim");
lease.Message.Event.EventId.Should().Be(notifyEvent.EventId);
await lease.AcknowledgeAsync();
}
private NatsNotifyEventQueue CreateQueue(NotifyEventQueueOptions options)
{
return new NatsNotifyEventQueue(
options,
options.Nats,
NullLogger<NatsNotifyEventQueue>.Instance,
TimeProvider.System);
}
private NotifyEventQueueOptions CreateOptions()
{
var connectionUrl = $"nats://{_nats.Hostname}:{_nats.GetMappedPublicPort(4222)}";
return new NotifyEventQueueOptions
{
Transport = NotifyQueueTransportKind.Nats,
DefaultLeaseDuration = TimeSpan.FromSeconds(2),
MaxDeliveryAttempts = 3,
RetryInitialBackoff = TimeSpan.FromMilliseconds(50),
RetryMaxBackoff = TimeSpan.FromSeconds(1),
Nats = new NotifyNatsEventQueueOptions
{
Url = connectionUrl,
Stream = "NOTIFY_TEST",
Subject = "notify.test.events",
DeadLetterStream = "NOTIFY_TEST_DEAD",
DeadLetterSubject = "notify.test.events.dead",
DurableConsumer = "notify-test-consumer",
MaxAckPending = 32,
AckWait = TimeSpan.FromSeconds(2),
RetryDelay = TimeSpan.FromMilliseconds(100),
IdleHeartbeat = TimeSpan.FromMilliseconds(100)
}
};
}
private bool SkipIfUnavailable()
=> _skipReason is not null;
private static class TestData
{
public static NotifyEvent CreateEvent(string tenant = "tenant-1")
{
return NotifyEvent.Create(
Guid.NewGuid(),
kind: "scanner.report.ready",
tenant: tenant,
ts: DateTimeOffset.UtcNow,
payload: new JsonObject
{
["summary"] = "event"
});
}
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using DotNet.Testcontainers.Configurations;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Notify.Models;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Queue.Nats;
using Xunit;
namespace StellaOps.Notify.Queue.Tests;
public sealed class NatsNotifyEventQueueTests : IAsyncLifetime
{
private readonly TestcontainersContainer _nats;
private string? _skipReason;
public NatsNotifyEventQueueTests()
{
_nats = new TestcontainersBuilder<TestcontainersContainer>()
.WithImage("nats:2.10-alpine")
.WithCleanUp(true)
.WithName($"nats-notify-tests-{Guid.NewGuid():N}")
.WithPortBinding(4222, true)
.WithCommand("--jetstream")
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(4222))
.Build();
}
public async Task InitializeAsync()
{
try
{
await _nats.StartAsync();
}
catch (Exception ex)
{
_skipReason = $"NATS-backed tests skipped: {ex.Message}";
}
}
public async Task DisposeAsync()
{
if (_skipReason is not null)
{
return;
}
await _nats.DisposeAsync().ConfigureAwait(false);
}
[Fact]
public async Task Publish_ShouldDeduplicate_ByIdempotencyKey()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var notifyEvent = TestData.CreateEvent("tenant-a");
var message = new NotifyQueueEventMessage(
notifyEvent,
options.Nats.Subject,
traceId: "trace-1");
var first = await queue.PublishAsync(message);
first.Deduplicated.Should().BeFalse();
var second = await queue.PublishAsync(message);
second.Deduplicated.Should().BeTrue();
second.MessageId.Should().Be(first.MessageId);
}
[Fact]
public async Task Lease_Acknowledge_ShouldRemoveMessage()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var notifyEvent = TestData.CreateEvent("tenant-b");
var message = new NotifyQueueEventMessage(
notifyEvent,
options.Nats.Subject,
traceId: "trace-xyz",
attributes: new Dictionary<string, string> { { "source", "scanner" } });
await queue.PublishAsync(message);
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(2)));
leases.Should().ContainSingle();
var lease = leases[0];
lease.Attempt.Should().BeGreaterThanOrEqualTo(1);
lease.Message.Event.EventId.Should().Be(notifyEvent.EventId);
lease.TraceId.Should().Be("trace-xyz");
lease.Attributes.Should().ContainKey("source").WhoseValue.Should().Be("scanner");
await lease.AcknowledgeAsync();
var afterAck = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(1)));
afterAck.Should().BeEmpty();
}
[Fact]
public async Task Lease_ShouldPreserveOrdering()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var first = TestData.CreateEvent();
var second = TestData.CreateEvent();
await queue.PublishAsync(new NotifyQueueEventMessage(first, options.Nats.Subject));
await queue.PublishAsync(new NotifyQueueEventMessage(second, options.Nats.Subject));
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-order", 2, TimeSpan.FromSeconds(2)));
leases.Should().HaveCount(2);
leases.Select(x => x.Message.Event.EventId)
.Should()
.ContainInOrder(first.EventId, second.EventId);
}
[Fact]
public async Task ClaimExpired_ShouldReassignLease()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var notifyEvent = TestData.CreateEvent();
await queue.PublishAsync(new NotifyQueueEventMessage(notifyEvent, options.Nats.Subject));
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-initial", 1, TimeSpan.FromMilliseconds(500)));
leases.Should().ContainSingle();
await Task.Delay(200);
var claimed = await queue.ClaimExpiredAsync(new NotifyQueueClaimOptions("worker-reclaim", 1, TimeSpan.FromMilliseconds(100)));
claimed.Should().ContainSingle();
var lease = claimed[0];
lease.Consumer.Should().Be("worker-reclaim");
lease.Message.Event.EventId.Should().Be(notifyEvent.EventId);
await lease.AcknowledgeAsync();
}
private NatsNotifyEventQueue CreateQueue(NotifyEventQueueOptions options)
{
return new NatsNotifyEventQueue(
options,
options.Nats,
NullLogger<NatsNotifyEventQueue>.Instance,
TimeProvider.System);
}
private NotifyEventQueueOptions CreateOptions()
{
var connectionUrl = $"nats://{_nats.Hostname}:{_nats.GetMappedPublicPort(4222)}";
return new NotifyEventQueueOptions
{
Transport = NotifyQueueTransportKind.Nats,
DefaultLeaseDuration = TimeSpan.FromSeconds(2),
MaxDeliveryAttempts = 3,
RetryInitialBackoff = TimeSpan.FromMilliseconds(50),
RetryMaxBackoff = TimeSpan.FromSeconds(1),
Nats = new NotifyNatsEventQueueOptions
{
Url = connectionUrl,
Stream = "NOTIFY_TEST",
Subject = "notify.test.events",
DeadLetterStream = "NOTIFY_TEST_DEAD",
DeadLetterSubject = "notify.test.events.dead",
DurableConsumer = "notify-test-consumer",
MaxAckPending = 32,
AckWait = TimeSpan.FromSeconds(2),
RetryDelay = TimeSpan.FromMilliseconds(100),
IdleHeartbeat = TimeSpan.FromMilliseconds(100)
}
};
}
private bool SkipIfUnavailable()
=> _skipReason is not null;
private static class TestData
{
public static NotifyEvent CreateEvent(string tenant = "tenant-1")
{
return NotifyEvent.Create(
Guid.NewGuid(),
kind: "scanner.report.ready",
tenant: tenant,
ts: DateTimeOffset.UtcNow,
payload: new JsonObject
{
["summary"] = "event"
});
}
}
}

View File

@@ -1,197 +1,197 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using DotNet.Testcontainers.Configurations;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StackExchange.Redis;
using StellaOps.Notify.Models;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Queue.Redis;
using Xunit;
namespace StellaOps.Notify.Queue.Tests;
public sealed class RedisNotifyDeliveryQueueTests : IAsyncLifetime
{
private readonly RedisTestcontainer _redis;
private string? _skipReason;
public RedisNotifyDeliveryQueueTests()
{
var configuration = new RedisTestcontainerConfiguration();
_redis = new TestcontainersBuilder<RedisTestcontainer>()
.WithDatabase(configuration)
.Build();
}
public async Task InitializeAsync()
{
try
{
await _redis.StartAsync();
}
catch (Exception ex)
{
_skipReason = $"Redis-backed delivery tests skipped: {ex.Message}";
}
}
public async Task DisposeAsync()
{
if (_skipReason is not null)
{
return;
}
await _redis.DisposeAsync().AsTask();
}
[Fact]
public async Task Publish_ShouldDeduplicate_ByDeliveryId()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var delivery = TestData.CreateDelivery();
var message = new NotifyDeliveryQueueMessage(
delivery,
channelId: "channel-1",
channelType: NotifyChannelType.Slack);
var first = await queue.PublishAsync(message);
first.Deduplicated.Should().BeFalse();
var second = await queue.PublishAsync(message);
second.Deduplicated.Should().BeTrue();
second.MessageId.Should().Be(first.MessageId);
}
[Fact]
public async Task Release_Retry_ShouldRescheduleDelivery()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
await queue.PublishAsync(new NotifyDeliveryQueueMessage(
TestData.CreateDelivery(),
channelId: "channel-retry",
channelType: NotifyChannelType.Teams));
var lease = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(1)))).Single();
lease.Attempt.Should().Be(1);
await lease.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
var retried = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(1)))).Single();
retried.Attempt.Should().Be(2);
await retried.AcknowledgeAsync();
}
[Fact]
public async Task Release_RetryBeyondMax_ShouldDeadLetter()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions(static opts =>
{
opts.MaxDeliveryAttempts = 2;
opts.Redis.DeadLetterStreamName = "notify:deliveries:testdead";
});
await using var queue = CreateQueue(options);
await queue.PublishAsync(new NotifyDeliveryQueueMessage(
TestData.CreateDelivery(),
channelId: "channel-dead",
channelType: NotifyChannelType.Email));
var first = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(1)))).Single();
await first.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
var second = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(1)))).Single();
await second.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
await Task.Delay(100);
var mux = await ConnectionMultiplexer.ConnectAsync(_redis.ConnectionString);
var db = mux.GetDatabase();
var deadLetters = await db.StreamReadAsync(options.Redis.DeadLetterStreamName, "0-0");
deadLetters.Should().NotBeEmpty();
}
private RedisNotifyDeliveryQueue CreateQueue(NotifyDeliveryQueueOptions options)
{
return new RedisNotifyDeliveryQueue(
options,
options.Redis,
NullLogger<RedisNotifyDeliveryQueue>.Instance,
TimeProvider.System,
async config => (IConnectionMultiplexer)await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false));
}
private NotifyDeliveryQueueOptions CreateOptions(Action<NotifyDeliveryQueueOptions>? configure = null)
{
var opts = new NotifyDeliveryQueueOptions
{
Transport = NotifyQueueTransportKind.Redis,
DefaultLeaseDuration = TimeSpan.FromSeconds(1),
MaxDeliveryAttempts = 3,
RetryInitialBackoff = TimeSpan.FromMilliseconds(10),
RetryMaxBackoff = TimeSpan.FromMilliseconds(50),
ClaimIdleThreshold = TimeSpan.FromSeconds(1),
Redis = new NotifyRedisDeliveryQueueOptions
{
ConnectionString = _redis.ConnectionString,
StreamName = "notify:deliveries:test",
ConsumerGroup = "notify-delivery-tests",
IdempotencyKeyPrefix = "notify:deliveries:test:idemp:"
}
};
configure?.Invoke(opts);
return opts;
}
private bool SkipIfUnavailable()
=> _skipReason is not null;
private static class TestData
{
public static NotifyDelivery CreateDelivery()
{
var now = DateTimeOffset.UtcNow;
return NotifyDelivery.Create(
deliveryId: Guid.NewGuid().ToString("n"),
tenantId: "tenant-1",
ruleId: "rule-1",
actionId: "action-1",
eventId: Guid.NewGuid(),
kind: "scanner.report.ready",
status: NotifyDeliveryStatus.Pending,
createdAt: now,
metadata: new Dictionary<string, string>
{
["integration"] = "tests"
});
}
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using DotNet.Testcontainers.Configurations;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StackExchange.Redis;
using StellaOps.Notify.Models;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Queue.Redis;
using Xunit;
namespace StellaOps.Notify.Queue.Tests;
public sealed class RedisNotifyDeliveryQueueTests : IAsyncLifetime
{
private readonly RedisTestcontainer _redis;
private string? _skipReason;
public RedisNotifyDeliveryQueueTests()
{
var configuration = new RedisTestcontainerConfiguration();
_redis = new TestcontainersBuilder<RedisTestcontainer>()
.WithDatabase(configuration)
.Build();
}
public async Task InitializeAsync()
{
try
{
await _redis.StartAsync();
}
catch (Exception ex)
{
_skipReason = $"Redis-backed delivery tests skipped: {ex.Message}";
}
}
public async Task DisposeAsync()
{
if (_skipReason is not null)
{
return;
}
await _redis.DisposeAsync().AsTask();
}
[Fact]
public async Task Publish_ShouldDeduplicate_ByDeliveryId()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var delivery = TestData.CreateDelivery();
var message = new NotifyDeliveryQueueMessage(
delivery,
channelId: "channel-1",
channelType: NotifyChannelType.Slack);
var first = await queue.PublishAsync(message);
first.Deduplicated.Should().BeFalse();
var second = await queue.PublishAsync(message);
second.Deduplicated.Should().BeTrue();
second.MessageId.Should().Be(first.MessageId);
}
[Fact]
public async Task Release_Retry_ShouldRescheduleDelivery()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
await queue.PublishAsync(new NotifyDeliveryQueueMessage(
TestData.CreateDelivery(),
channelId: "channel-retry",
channelType: NotifyChannelType.Teams));
var lease = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(1)))).Single();
lease.Attempt.Should().Be(1);
await lease.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
var retried = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-retry", 1, TimeSpan.FromSeconds(1)))).Single();
retried.Attempt.Should().Be(2);
await retried.AcknowledgeAsync();
}
[Fact]
public async Task Release_RetryBeyondMax_ShouldDeadLetter()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions(static opts =>
{
opts.MaxDeliveryAttempts = 2;
opts.Redis.DeadLetterStreamName = "notify:deliveries:testdead";
});
await using var queue = CreateQueue(options);
await queue.PublishAsync(new NotifyDeliveryQueueMessage(
TestData.CreateDelivery(),
channelId: "channel-dead",
channelType: NotifyChannelType.Email));
var first = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(1)))).Single();
await first.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
var second = (await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-dead", 1, TimeSpan.FromSeconds(1)))).Single();
await second.ReleaseAsync(NotifyQueueReleaseDisposition.Retry);
await Task.Delay(100);
var mux = await ConnectionMultiplexer.ConnectAsync(_redis.ConnectionString);
var db = mux.GetDatabase();
var deadLetters = await db.StreamReadAsync(options.Redis.DeadLetterStreamName, "0-0");
deadLetters.Should().NotBeEmpty();
}
private RedisNotifyDeliveryQueue CreateQueue(NotifyDeliveryQueueOptions options)
{
return new RedisNotifyDeliveryQueue(
options,
options.Redis,
NullLogger<RedisNotifyDeliveryQueue>.Instance,
TimeProvider.System,
async config => (IConnectionMultiplexer)await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false));
}
private NotifyDeliveryQueueOptions CreateOptions(Action<NotifyDeliveryQueueOptions>? configure = null)
{
var opts = new NotifyDeliveryQueueOptions
{
Transport = NotifyQueueTransportKind.Redis,
DefaultLeaseDuration = TimeSpan.FromSeconds(1),
MaxDeliveryAttempts = 3,
RetryInitialBackoff = TimeSpan.FromMilliseconds(10),
RetryMaxBackoff = TimeSpan.FromMilliseconds(50),
ClaimIdleThreshold = TimeSpan.FromSeconds(1),
Redis = new NotifyRedisDeliveryQueueOptions
{
ConnectionString = _redis.ConnectionString,
StreamName = "notify:deliveries:test",
ConsumerGroup = "notify-delivery-tests",
IdempotencyKeyPrefix = "notify:deliveries:test:idemp:"
}
};
configure?.Invoke(opts);
return opts;
}
private bool SkipIfUnavailable()
=> _skipReason is not null;
private static class TestData
{
public static NotifyDelivery CreateDelivery()
{
var now = DateTimeOffset.UtcNow;
return NotifyDelivery.Create(
deliveryId: Guid.NewGuid().ToString("n"),
tenantId: "tenant-1",
ruleId: "rule-1",
actionId: "action-1",
eventId: Guid.NewGuid(),
kind: "scanner.report.ready",
status: NotifyDeliveryStatus.Pending,
createdAt: now,
metadata: new Dictionary<string, string>
{
["integration"] = "tests"
});
}
}
}

View File

@@ -1,220 +1,220 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using DotNet.Testcontainers.Configurations;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StackExchange.Redis;
using StellaOps.Notify.Models;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Queue.Redis;
using Xunit;
namespace StellaOps.Notify.Queue.Tests;
public sealed class RedisNotifyEventQueueTests : IAsyncLifetime
{
private readonly RedisTestcontainer _redis;
private string? _skipReason;
public RedisNotifyEventQueueTests()
{
var configuration = new RedisTestcontainerConfiguration();
_redis = new TestcontainersBuilder<RedisTestcontainer>()
.WithDatabase(configuration)
.Build();
}
public async Task InitializeAsync()
{
try
{
await _redis.StartAsync();
}
catch (Exception ex)
{
_skipReason = $"Redis-backed tests skipped: {ex.Message}";
}
}
public async Task DisposeAsync()
{
if (_skipReason is not null)
{
return;
}
await _redis.DisposeAsync().AsTask();
}
[Fact]
public async Task Publish_ShouldDeduplicate_ByIdempotencyKey()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var notifyEvent = TestData.CreateEvent(tenant: "tenant-a");
var message = new NotifyQueueEventMessage(notifyEvent, options.Redis.Streams[0].Stream);
var first = await queue.PublishAsync(message);
first.Deduplicated.Should().BeFalse();
var second = await queue.PublishAsync(message);
second.Deduplicated.Should().BeTrue();
second.MessageId.Should().Be(first.MessageId);
}
[Fact]
public async Task Lease_Acknowledge_ShouldRemoveMessage()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var notifyEvent = TestData.CreateEvent(tenant: "tenant-b");
var message = new NotifyQueueEventMessage(
notifyEvent,
options.Redis.Streams[0].Stream,
traceId: "trace-123",
attributes: new Dictionary<string, string> { { "source", "scanner" } });
await queue.PublishAsync(message);
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(5)));
leases.Should().ContainSingle();
var lease = leases[0];
lease.Attempt.Should().Be(1);
lease.Message.Event.EventId.Should().Be(notifyEvent.EventId);
lease.TraceId.Should().Be("trace-123");
lease.Attributes.Should().ContainKey("source").WhoseValue.Should().Be("scanner");
await lease.AcknowledgeAsync();
var afterAck = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(5)));
afterAck.Should().BeEmpty();
}
[Fact]
public async Task Lease_ShouldPreserveOrdering()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var stream = options.Redis.Streams[0].Stream;
var firstEvent = TestData.CreateEvent();
var secondEvent = TestData.CreateEvent();
await queue.PublishAsync(new NotifyQueueEventMessage(firstEvent, stream));
await queue.PublishAsync(new NotifyQueueEventMessage(secondEvent, stream));
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-order", 2, TimeSpan.FromSeconds(5)));
leases.Should().HaveCount(2);
leases.Select(l => l.Message.Event.EventId)
.Should()
.ContainInOrder(new[] { firstEvent.EventId, secondEvent.EventId });
}
[Fact]
public async Task ClaimExpired_ShouldReassignLease()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var notifyEvent = TestData.CreateEvent();
await queue.PublishAsync(new NotifyQueueEventMessage(notifyEvent, options.Redis.Streams[0].Stream));
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-initial", 1, TimeSpan.FromSeconds(1)));
leases.Should().ContainSingle();
// Ensure the message has been pending long enough for claim.
await Task.Delay(50);
var claimed = await queue.ClaimExpiredAsync(new NotifyQueueClaimOptions("worker-reclaim", 1, TimeSpan.Zero));
claimed.Should().ContainSingle();
var lease = claimed[0];
lease.Consumer.Should().Be("worker-reclaim");
lease.Message.Event.EventId.Should().Be(notifyEvent.EventId);
await lease.AcknowledgeAsync();
}
private RedisNotifyEventQueue CreateQueue(NotifyEventQueueOptions options)
{
return new RedisNotifyEventQueue(
options,
options.Redis,
NullLogger<RedisNotifyEventQueue>.Instance,
TimeProvider.System,
async config => (IConnectionMultiplexer)await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false));
}
private NotifyEventQueueOptions CreateOptions()
{
var streamOptions = new NotifyRedisEventStreamOptions
{
Stream = "notify:test:events",
ConsumerGroup = "notify-test-consumers",
IdempotencyKeyPrefix = "notify:test:idemp:",
ApproximateMaxLength = 1024
};
var redisOptions = new NotifyRedisEventQueueOptions
{
ConnectionString = _redis.ConnectionString,
Streams = new List<NotifyRedisEventStreamOptions> { streamOptions }
};
return new NotifyEventQueueOptions
{
Transport = NotifyQueueTransportKind.Redis,
DefaultLeaseDuration = TimeSpan.FromSeconds(5),
Redis = redisOptions
};
}
private bool SkipIfUnavailable()
=> _skipReason is not null;
private static class TestData
{
public static NotifyEvent CreateEvent(string tenant = "tenant-1")
{
return NotifyEvent.Create(
Guid.NewGuid(),
kind: "scanner.report.ready",
tenant: tenant,
ts: DateTimeOffset.UtcNow,
payload: new JsonObject
{
["summary"] = "event"
});
}
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using DotNet.Testcontainers.Configurations;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StackExchange.Redis;
using StellaOps.Notify.Models;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Queue.Redis;
using Xunit;
namespace StellaOps.Notify.Queue.Tests;
public sealed class RedisNotifyEventQueueTests : IAsyncLifetime
{
private readonly RedisTestcontainer _redis;
private string? _skipReason;
public RedisNotifyEventQueueTests()
{
var configuration = new RedisTestcontainerConfiguration();
_redis = new TestcontainersBuilder<RedisTestcontainer>()
.WithDatabase(configuration)
.Build();
}
public async Task InitializeAsync()
{
try
{
await _redis.StartAsync();
}
catch (Exception ex)
{
_skipReason = $"Redis-backed tests skipped: {ex.Message}";
}
}
public async Task DisposeAsync()
{
if (_skipReason is not null)
{
return;
}
await _redis.DisposeAsync().AsTask();
}
[Fact]
public async Task Publish_ShouldDeduplicate_ByIdempotencyKey()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var notifyEvent = TestData.CreateEvent(tenant: "tenant-a");
var message = new NotifyQueueEventMessage(notifyEvent, options.Redis.Streams[0].Stream);
var first = await queue.PublishAsync(message);
first.Deduplicated.Should().BeFalse();
var second = await queue.PublishAsync(message);
second.Deduplicated.Should().BeTrue();
second.MessageId.Should().Be(first.MessageId);
}
[Fact]
public async Task Lease_Acknowledge_ShouldRemoveMessage()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var notifyEvent = TestData.CreateEvent(tenant: "tenant-b");
var message = new NotifyQueueEventMessage(
notifyEvent,
options.Redis.Streams[0].Stream,
traceId: "trace-123",
attributes: new Dictionary<string, string> { { "source", "scanner" } });
await queue.PublishAsync(message);
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(5)));
leases.Should().ContainSingle();
var lease = leases[0];
lease.Attempt.Should().Be(1);
lease.Message.Event.EventId.Should().Be(notifyEvent.EventId);
lease.TraceId.Should().Be("trace-123");
lease.Attributes.Should().ContainKey("source").WhoseValue.Should().Be("scanner");
await lease.AcknowledgeAsync();
var afterAck = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-1", 1, TimeSpan.FromSeconds(5)));
afterAck.Should().BeEmpty();
}
[Fact]
public async Task Lease_ShouldPreserveOrdering()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var stream = options.Redis.Streams[0].Stream;
var firstEvent = TestData.CreateEvent();
var secondEvent = TestData.CreateEvent();
await queue.PublishAsync(new NotifyQueueEventMessage(firstEvent, stream));
await queue.PublishAsync(new NotifyQueueEventMessage(secondEvent, stream));
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-order", 2, TimeSpan.FromSeconds(5)));
leases.Should().HaveCount(2);
leases.Select(l => l.Message.Event.EventId)
.Should()
.ContainInOrder(new[] { firstEvent.EventId, secondEvent.EventId });
}
[Fact]
public async Task ClaimExpired_ShouldReassignLease()
{
if (SkipIfUnavailable())
{
return;
}
var options = CreateOptions();
await using var queue = CreateQueue(options);
var notifyEvent = TestData.CreateEvent();
await queue.PublishAsync(new NotifyQueueEventMessage(notifyEvent, options.Redis.Streams[0].Stream));
var leases = await queue.LeaseAsync(new NotifyQueueLeaseRequest("worker-initial", 1, TimeSpan.FromSeconds(1)));
leases.Should().ContainSingle();
// Ensure the message has been pending long enough for claim.
await Task.Delay(50);
var claimed = await queue.ClaimExpiredAsync(new NotifyQueueClaimOptions("worker-reclaim", 1, TimeSpan.Zero));
claimed.Should().ContainSingle();
var lease = claimed[0];
lease.Consumer.Should().Be("worker-reclaim");
lease.Message.Event.EventId.Should().Be(notifyEvent.EventId);
await lease.AcknowledgeAsync();
}
private RedisNotifyEventQueue CreateQueue(NotifyEventQueueOptions options)
{
return new RedisNotifyEventQueue(
options,
options.Redis,
NullLogger<RedisNotifyEventQueue>.Instance,
TimeProvider.System,
async config => (IConnectionMultiplexer)await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false));
}
private NotifyEventQueueOptions CreateOptions()
{
var streamOptions = new NotifyRedisEventStreamOptions
{
Stream = "notify:test:events",
ConsumerGroup = "notify-test-consumers",
IdempotencyKeyPrefix = "notify:test:idemp:",
ApproximateMaxLength = 1024
};
var redisOptions = new NotifyRedisEventQueueOptions
{
ConnectionString = _redis.ConnectionString,
Streams = new List<NotifyRedisEventStreamOptions> { streamOptions }
};
return new NotifyEventQueueOptions
{
Transport = NotifyQueueTransportKind.Redis,
DefaultLeaseDuration = TimeSpan.FromSeconds(5),
Redis = redisOptions
};
}
private bool SkipIfUnavailable()
=> _skipReason is not null;
private static class TestData
{
public static NotifyEvent CreateEvent(string tenant = "tenant-1")
{
return NotifyEvent.Create(
Guid.NewGuid(),
kind: "scanner.report.ready",
tenant: tenant,
ts: DateTimeOffset.UtcNow,
payload: new JsonObject
{
["summary"] = "event"
});
}
}
}

View File

@@ -1,86 +1,86 @@
using System.Net.Http.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc.Testing;
namespace StellaOps.Notify.WebService.Tests;
public sealed class NormalizeEndpointsTests : IClassFixture<WebApplicationFactory<Program>>, IAsyncLifetime
{
private readonly WebApplicationFactory<Program> _factory;
public NormalizeEndpointsTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.UseSetting("notify:storage:driver", "memory");
builder.UseSetting("notify:authority:enabled", "false");
builder.UseSetting("notify:authority:developmentSigningKey", "normalize-tests-signing-key-1234567890");
builder.UseSetting("notify:authority:issuer", "test-issuer");
builder.UseSetting("notify:authority:audiences:0", "notify");
builder.UseSetting("notify:telemetry:enableRequestLogging", "false");
});
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task RuleNormalizeAddsSchemaVersion()
{
var client = _factory.CreateClient();
var payload = LoadSampleNode("notify-rule@1.sample.json");
payload!.AsObject().Remove("schemaVersion");
var response = await client.PostAsJsonAsync("/internal/notify/rules/normalize", payload);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
var normalized = JsonNode.Parse(content);
Assert.Equal("notify.rule@1", normalized?["schemaVersion"]?.GetValue<string>());
}
[Fact]
public async Task ChannelNormalizeAddsSchemaVersion()
{
var client = _factory.CreateClient();
var payload = LoadSampleNode("notify-channel@1.sample.json");
payload!.AsObject().Remove("schemaVersion");
var response = await client.PostAsJsonAsync("/internal/notify/channels/normalize", payload);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
var normalized = JsonNode.Parse(content);
Assert.Equal("notify.channel@1", normalized?["schemaVersion"]?.GetValue<string>());
}
[Fact]
public async Task TemplateNormalizeAddsSchemaVersion()
{
var client = _factory.CreateClient();
var payload = LoadSampleNode("notify-template@1.sample.json");
payload!.AsObject().Remove("schemaVersion");
var response = await client.PostAsJsonAsync("/internal/notify/templates/normalize", payload);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
var normalized = JsonNode.Parse(content);
Assert.Equal("notify.template@1", normalized?["schemaVersion"]?.GetValue<string>());
}
private static JsonNode? LoadSampleNode(string fileName)
{
var path = Path.Combine(AppContext.BaseDirectory, fileName);
if (!File.Exists(path))
{
throw new FileNotFoundException($"Unable to load sample '{fileName}'.", path);
}
return JsonNode.Parse(File.ReadAllText(path));
}
}
using System.Net.Http.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc.Testing;
namespace StellaOps.Notify.WebService.Tests;
public sealed class NormalizeEndpointsTests : IClassFixture<WebApplicationFactory<Program>>, IAsyncLifetime
{
private readonly WebApplicationFactory<Program> _factory;
public NormalizeEndpointsTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.UseSetting("notify:storage:driver", "memory");
builder.UseSetting("notify:authority:enabled", "false");
builder.UseSetting("notify:authority:developmentSigningKey", "normalize-tests-signing-key-1234567890");
builder.UseSetting("notify:authority:issuer", "test-issuer");
builder.UseSetting("notify:authority:audiences:0", "notify");
builder.UseSetting("notify:telemetry:enableRequestLogging", "false");
});
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task RuleNormalizeAddsSchemaVersion()
{
var client = _factory.CreateClient();
var payload = LoadSampleNode("notify-rule@1.sample.json");
payload!.AsObject().Remove("schemaVersion");
var response = await client.PostAsJsonAsync("/internal/notify/rules/normalize", payload);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
var normalized = JsonNode.Parse(content);
Assert.Equal("notify.rule@1", normalized?["schemaVersion"]?.GetValue<string>());
}
[Fact]
public async Task ChannelNormalizeAddsSchemaVersion()
{
var client = _factory.CreateClient();
var payload = LoadSampleNode("notify-channel@1.sample.json");
payload!.AsObject().Remove("schemaVersion");
var response = await client.PostAsJsonAsync("/internal/notify/channels/normalize", payload);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
var normalized = JsonNode.Parse(content);
Assert.Equal("notify.channel@1", normalized?["schemaVersion"]?.GetValue<string>());
}
[Fact]
public async Task TemplateNormalizeAddsSchemaVersion()
{
var client = _factory.CreateClient();
var payload = LoadSampleNode("notify-template@1.sample.json");
payload!.AsObject().Remove("schemaVersion");
var response = await client.PostAsJsonAsync("/internal/notify/templates/normalize", payload);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
var normalized = JsonNode.Parse(content);
Assert.Equal("notify.template@1", normalized?["schemaVersion"]?.GetValue<string>());
}
private static JsonNode? LoadSampleNode(string fileName)
{
var path = Path.Combine(AppContext.BaseDirectory, fileName);
if (!File.Exists(path))
{
throw new FileNotFoundException($"Unable to load sample '{fileName}'.", path);
}
return JsonNode.Parse(File.ReadAllText(path));
}
}

View File

@@ -1,167 +1,167 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Models;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Worker;
using StellaOps.Notify.Worker.Handlers;
using StellaOps.Notify.Worker.Processing;
using Xunit;
namespace StellaOps.Notify.Worker.Tests;
public sealed class NotifyEventLeaseProcessorTests
{
[Fact]
public async Task ProcessOnce_ShouldAcknowledgeSuccessfulLease()
{
var lease = new FakeLease();
var queue = new FakeEventQueue(lease);
var handler = new TestHandler();
var options = Options.Create(new NotifyWorkerOptions { LeaseBatchSize = 1, LeaseDuration = TimeSpan.FromSeconds(5) });
var processor = new NotifyEventLeaseProcessor(queue, handler, options, NullLogger<NotifyEventLeaseProcessor>.Instance, TimeProvider.System);
var processed = await processor.ProcessOnceAsync(CancellationToken.None);
processed.Should().Be(1);
lease.AcknowledgeCount.Should().Be(1);
lease.ReleaseCount.Should().Be(0);
}
[Fact]
public async Task ProcessOnce_ShouldRetryOnHandlerFailure()
{
var lease = new FakeLease();
var queue = new FakeEventQueue(lease);
var handler = new TestHandler(shouldThrow: true);
var options = Options.Create(new NotifyWorkerOptions { LeaseBatchSize = 1, LeaseDuration = TimeSpan.FromSeconds(5) });
var processor = new NotifyEventLeaseProcessor(queue, handler, options, NullLogger<NotifyEventLeaseProcessor>.Instance, TimeProvider.System);
var processed = await processor.ProcessOnceAsync(CancellationToken.None);
processed.Should().Be(1);
lease.AcknowledgeCount.Should().Be(0);
lease.ReleaseCount.Should().Be(1);
lease.LastDisposition.Should().Be(NotifyQueueReleaseDisposition.Retry);
}
private sealed class FakeEventQueue : INotifyEventQueue
{
private readonly Queue<INotifyQueueLease<NotifyQueueEventMessage>> _leases;
public FakeEventQueue(params INotifyQueueLease<NotifyQueueEventMessage>[] leases)
{
_leases = new Queue<INotifyQueueLease<NotifyQueueEventMessage>>(leases);
}
public ValueTask<NotifyQueueEnqueueResult> PublishAsync(NotifyQueueEventMessage message, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public ValueTask<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>> LeaseAsync(NotifyQueueLeaseRequest request, CancellationToken cancellationToken = default)
{
if (_leases.Count == 0)
{
return ValueTask.FromResult<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>>(Array.Empty<INotifyQueueLease<NotifyQueueEventMessage>>());
}
return ValueTask.FromResult<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>>(new[] { _leases.Dequeue() });
}
public ValueTask<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>> ClaimExpiredAsync(NotifyQueueClaimOptions options, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>>(Array.Empty<INotifyQueueLease<NotifyQueueEventMessage>>());
}
private sealed class FakeLease : INotifyQueueLease<NotifyQueueEventMessage>
{
private readonly NotifyQueueEventMessage _message;
public FakeLease()
{
var notifyEvent = NotifyEvent.Create(
Guid.NewGuid(),
kind: "test.event",
tenant: "tenant-1",
ts: DateTimeOffset.UtcNow,
payload: null);
_message = new NotifyQueueEventMessage(notifyEvent, "notify:events", traceId: "trace-123");
}
public string MessageId { get; } = Guid.NewGuid().ToString("n");
public int Attempt { get; internal set; } = 1;
public DateTimeOffset EnqueuedAt { get; } = DateTimeOffset.UtcNow;
public DateTimeOffset LeaseExpiresAt { get; private set; } = DateTimeOffset.UtcNow.AddSeconds(30);
public string Consumer { get; } = "worker-1";
public string Stream => _message.Stream;
public string TenantId => _message.TenantId;
public string? PartitionKey => _message.PartitionKey;
public string IdempotencyKey => _message.IdempotencyKey;
public string? TraceId => _message.TraceId;
public IReadOnlyDictionary<string, string> Attributes => _message.Attributes;
public NotifyQueueEventMessage Message => _message;
public int AcknowledgeCount { get; private set; }
public int ReleaseCount { get; private set; }
public NotifyQueueReleaseDisposition? LastDisposition { get; private set; }
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
{
AcknowledgeCount++;
return Task.CompletedTask;
}
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
{
LeaseExpiresAt = DateTimeOffset.UtcNow.Add(leaseDuration);
return Task.CompletedTask;
}
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
{
LastDisposition = disposition;
ReleaseCount++;
Attempt++;
return Task.CompletedTask;
}
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
private sealed class TestHandler : INotifyEventHandler
{
private readonly bool _shouldThrow;
public TestHandler(bool shouldThrow = false)
{
_shouldThrow = shouldThrow;
}
public Task HandleAsync(NotifyQueueEventMessage message, CancellationToken cancellationToken)
{
if (_shouldThrow)
{
throw new InvalidOperationException("handler failure");
}
return Task.CompletedTask;
}
}
}
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Models;
using StellaOps.Notify.Queue;
using StellaOps.Notify.Worker;
using StellaOps.Notify.Worker.Handlers;
using StellaOps.Notify.Worker.Processing;
using Xunit;
namespace StellaOps.Notify.Worker.Tests;
public sealed class NotifyEventLeaseProcessorTests
{
[Fact]
public async Task ProcessOnce_ShouldAcknowledgeSuccessfulLease()
{
var lease = new FakeLease();
var queue = new FakeEventQueue(lease);
var handler = new TestHandler();
var options = Options.Create(new NotifyWorkerOptions { LeaseBatchSize = 1, LeaseDuration = TimeSpan.FromSeconds(5) });
var processor = new NotifyEventLeaseProcessor(queue, handler, options, NullLogger<NotifyEventLeaseProcessor>.Instance, TimeProvider.System);
var processed = await processor.ProcessOnceAsync(CancellationToken.None);
processed.Should().Be(1);
lease.AcknowledgeCount.Should().Be(1);
lease.ReleaseCount.Should().Be(0);
}
[Fact]
public async Task ProcessOnce_ShouldRetryOnHandlerFailure()
{
var lease = new FakeLease();
var queue = new FakeEventQueue(lease);
var handler = new TestHandler(shouldThrow: true);
var options = Options.Create(new NotifyWorkerOptions { LeaseBatchSize = 1, LeaseDuration = TimeSpan.FromSeconds(5) });
var processor = new NotifyEventLeaseProcessor(queue, handler, options, NullLogger<NotifyEventLeaseProcessor>.Instance, TimeProvider.System);
var processed = await processor.ProcessOnceAsync(CancellationToken.None);
processed.Should().Be(1);
lease.AcknowledgeCount.Should().Be(0);
lease.ReleaseCount.Should().Be(1);
lease.LastDisposition.Should().Be(NotifyQueueReleaseDisposition.Retry);
}
private sealed class FakeEventQueue : INotifyEventQueue
{
private readonly Queue<INotifyQueueLease<NotifyQueueEventMessage>> _leases;
public FakeEventQueue(params INotifyQueueLease<NotifyQueueEventMessage>[] leases)
{
_leases = new Queue<INotifyQueueLease<NotifyQueueEventMessage>>(leases);
}
public ValueTask<NotifyQueueEnqueueResult> PublishAsync(NotifyQueueEventMessage message, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public ValueTask<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>> LeaseAsync(NotifyQueueLeaseRequest request, CancellationToken cancellationToken = default)
{
if (_leases.Count == 0)
{
return ValueTask.FromResult<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>>(Array.Empty<INotifyQueueLease<NotifyQueueEventMessage>>());
}
return ValueTask.FromResult<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>>(new[] { _leases.Dequeue() });
}
public ValueTask<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>> ClaimExpiredAsync(NotifyQueueClaimOptions options, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<IReadOnlyList<INotifyQueueLease<NotifyQueueEventMessage>>>(Array.Empty<INotifyQueueLease<NotifyQueueEventMessage>>());
}
private sealed class FakeLease : INotifyQueueLease<NotifyQueueEventMessage>
{
private readonly NotifyQueueEventMessage _message;
public FakeLease()
{
var notifyEvent = NotifyEvent.Create(
Guid.NewGuid(),
kind: "test.event",
tenant: "tenant-1",
ts: DateTimeOffset.UtcNow,
payload: null);
_message = new NotifyQueueEventMessage(notifyEvent, "notify:events", traceId: "trace-123");
}
public string MessageId { get; } = Guid.NewGuid().ToString("n");
public int Attempt { get; internal set; } = 1;
public DateTimeOffset EnqueuedAt { get; } = DateTimeOffset.UtcNow;
public DateTimeOffset LeaseExpiresAt { get; private set; } = DateTimeOffset.UtcNow.AddSeconds(30);
public string Consumer { get; } = "worker-1";
public string Stream => _message.Stream;
public string TenantId => _message.TenantId;
public string? PartitionKey => _message.PartitionKey;
public string IdempotencyKey => _message.IdempotencyKey;
public string? TraceId => _message.TraceId;
public IReadOnlyDictionary<string, string> Attributes => _message.Attributes;
public NotifyQueueEventMessage Message => _message;
public int AcknowledgeCount { get; private set; }
public int ReleaseCount { get; private set; }
public NotifyQueueReleaseDisposition? LastDisposition { get; private set; }
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
{
AcknowledgeCount++;
return Task.CompletedTask;
}
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
{
LeaseExpiresAt = DateTimeOffset.UtcNow.Add(leaseDuration);
return Task.CompletedTask;
}
public Task ReleaseAsync(NotifyQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
{
LastDisposition = disposition;
ReleaseCount++;
Attempt++;
return Task.CompletedTask;
}
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
private sealed class TestHandler : INotifyEventHandler
{
private readonly bool _shouldThrow;
public TestHandler(bool shouldThrow = false)
{
_shouldThrow = shouldThrow;
}
public Task HandleAsync(NotifyQueueEventMessage message, CancellationToken cancellationToken)
{
if (_shouldThrow)
{
throw new InvalidOperationException("handler failure");
}
return Task.CompletedTask;
}
}
}