Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +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);
}
}

View File

@@ -0,0 +1,22 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Connectors.Email/StellaOps.Notify.Connectors.Email.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Engine/StellaOps.Notify.Engine.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
</ItemGroup>
</Project>

View File

@@ -0,0 +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();
}
}

View File

@@ -0,0 +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();
}
}

View File

@@ -0,0 +1,22 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Connectors.Slack/StellaOps.Notify.Connectors.Slack.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Engine/StellaOps.Notify.Engine.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,22 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Connectors.Teams/StellaOps.Notify.Connectors.Teams.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Engine/StellaOps.Notify.Engine.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
</ItemGroup>
</Project>

View File

@@ -0,0 +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();
}
}

View File

@@ -0,0 +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();
}
}

View File

@@ -0,0 +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);
}
}

View File

@@ -0,0 +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.");
}
}

View File

@@ -0,0 +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);
}
}

View File

@@ -0,0 +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);
}
}

View File

@@ -0,0 +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);
}
}

View File

@@ -0,0 +1,52 @@
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)]
[InlineData("attestor.logged@1.sample.json", NotifyEventKinds.AttestorLogged)]
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);
}
}

View File

@@ -0,0 +1,44 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using NJsonSchema;
using Xunit;
using System.Threading.Tasks;
namespace StellaOps.Notify.Models.Tests;
public sealed class PlatformEventSchemaValidationTests
{
public static IEnumerable<object[]> SampleFiles() => new[]
{
new object[] { "scanner.report.ready@1.sample.json", "scanner.report.ready@1.json" },
new object[] { "scanner.scan.completed@1.sample.json", "scanner.scan.completed@1.json" },
new object[] { "scheduler.rescan.delta@1.sample.json", "scheduler.rescan.delta@1.json" },
new object[] { "attestor.logged@1.sample.json", "attestor.logged@1.json" }
};
[Theory]
[MemberData(nameof(SampleFiles))]
public async Task EventSamplesConformToPublishedSchemas(string sampleFile, string schemaFile)
{
var baseDirectory = AppContext.BaseDirectory;
var samplePath = Path.Combine(baseDirectory, sampleFile);
var schemaPath = Path.Combine(baseDirectory, schemaFile);
Assert.True(File.Exists(samplePath), $"Sample '{sampleFile}' not found at '{samplePath}'.");
Assert.True(File.Exists(schemaPath), $"Schema '{schemaFile}' not found at '{schemaPath}'.");
var schema = await JsonSchema.FromJsonAsync(File.ReadAllText(schemaPath));
var errors = schema.Validate(File.ReadAllText(samplePath));
if (errors.Count > 0)
{
var formatted = string.Join(
Environment.NewLine,
errors.Select(error => $"{error.Path}: {error.Kind} ({error})"));
Assert.True(errors.Count == 0, $"Schema validation failed for '{sampleFile}':{Environment.NewLine}{formatted}");
}
}
}

View File

@@ -0,0 +1,25 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="NJsonSchema" Version="10.9.0" />
<None Include="../../docs/events/samples/*.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Include="../../docs/events/*.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Include="../../docs/notify/samples/*.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +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);
}
}
}

View File

@@ -0,0 +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"
});
}
}
}

View File

@@ -0,0 +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"
});
}
}
}

View File

@@ -0,0 +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"
});
}
}
}

View File

@@ -0,0 +1,27 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="DotNet.Testcontainers" Version="1.7.0-beta.2269" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Queue/StellaOps.Notify.Queue.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,3 @@
using Xunit;
[assembly: CollectionBehavior(DisableTestParallelization = true)]

View File

@@ -0,0 +1 @@
global using Xunit;

View File

@@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Mongo2Go;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Notify.Storage.Mongo.Internal;
using StellaOps.Notify.Storage.Mongo.Migrations;
using StellaOps.Notify.Storage.Mongo.Options;
namespace StellaOps.Notify.Storage.Mongo.Tests.Internal;
public sealed class NotifyMongoMigrationTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner = MongoDbRunner.Start(singleNodeReplSet: true);
private readonly NotifyMongoContext _context;
private readonly NotifyMongoInitializer _initializer;
public NotifyMongoMigrationTests()
{
var options = Microsoft.Extensions.Options.Options.Create(new NotifyMongoOptions
{
ConnectionString = _runner.ConnectionString,
Database = "notify-migration-tests",
DeliveryHistoryRetention = TimeSpan.FromDays(45),
MigrationsCollection = "notify_migrations_tests"
});
_context = new NotifyMongoContext(options, NullLogger<NotifyMongoContext>.Instance);
_initializer = CreateInitializer(_context);
}
public async Task InitializeAsync()
{
await _initializer.EnsureIndexesAsync();
}
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
[Fact]
public async Task EnsureIndexesCreatesExpectedDefinitions()
{
// run twice to ensure idempotency
await _initializer.EnsureIndexesAsync();
var deliveriesIndexes = await GetIndexesAsync(_context.Options.DeliveriesCollection);
Assert.Contains("tenant_sortKey", deliveriesIndexes.Select(doc => doc["name"].AsString));
Assert.Contains("tenant_status", deliveriesIndexes.Select(doc => doc["name"].AsString));
var ttlIndex = deliveriesIndexes.Single(doc => doc["name"].AsString == "completedAt_ttl");
Assert.Equal(_context.Options.DeliveryHistoryRetention.TotalSeconds, ttlIndex["expireAfterSeconds"].ToDouble());
var locksIndexes = await GetIndexesAsync(_context.Options.LocksCollection);
Assert.Contains("tenant_resource", locksIndexes.Select(doc => doc["name"].AsString));
Assert.True(locksIndexes.Single(doc => doc["name"].AsString == "tenant_resource")["unique"].ToBoolean());
Assert.Contains("expiresAt_ttl", locksIndexes.Select(doc => doc["name"].AsString));
var digestsIndexes = await GetIndexesAsync(_context.Options.DigestsCollection);
Assert.Contains("tenant_actionKey", digestsIndexes.Select(doc => doc["name"].AsString));
var rulesIndexes = await GetIndexesAsync(_context.Options.RulesCollection);
Assert.Contains("tenant_enabled", rulesIndexes.Select(doc => doc["name"].AsString));
var migrationsIndexes = await GetIndexesAsync(_context.Options.MigrationsCollection);
Assert.Contains("migrationId_unique", migrationsIndexes.Select(doc => doc["name"].AsString));
}
private async Task<IReadOnlyList<BsonDocument>> GetIndexesAsync(string collectionName)
{
var collection = _context.Database.GetCollection<BsonDocument>(collectionName);
var cursor = await collection.Indexes.ListAsync().ConfigureAwait(false);
return await cursor.ToListAsync().ConfigureAwait(false);
}
private static NotifyMongoInitializer CreateInitializer(NotifyMongoContext context)
{
var migrations = new INotifyMongoMigration[]
{
new EnsureNotifyCollectionsMigration(NullLogger<EnsureNotifyCollectionsMigration>.Instance),
new EnsureNotifyIndexesMigration()
};
var runner = new NotifyMongoMigrationRunner(context, migrations, NullLogger<NotifyMongoMigrationRunner>.Instance);
return new NotifyMongoInitializer(context, runner, NullLogger<NotifyMongoInitializer>.Instance);
}
}

View File

@@ -0,0 +1,75 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Mongo2Go;
using MongoDB.Bson;
using StellaOps.Notify.Storage.Mongo.Documents;
using StellaOps.Notify.Storage.Mongo.Internal;
using StellaOps.Notify.Storage.Mongo.Migrations;
using StellaOps.Notify.Storage.Mongo.Options;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notify.Storage.Mongo.Tests.Repositories;
public sealed class NotifyAuditRepositoryTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner = MongoDbRunner.Start(singleNodeReplSet: true);
private readonly NotifyMongoContext _context;
private readonly NotifyMongoInitializer _initializer;
private readonly NotifyAuditRepository _repository;
public NotifyAuditRepositoryTests()
{
var options = Microsoft.Extensions.Options.Options.Create(new NotifyMongoOptions
{
ConnectionString = _runner.ConnectionString,
Database = "notify-audit-tests"
});
_context = new NotifyMongoContext(options, NullLogger<NotifyMongoContext>.Instance);
_initializer = CreateInitializer(_context);
_repository = new NotifyAuditRepository(_context);
}
public async Task InitializeAsync()
{
await _initializer.EnsureIndexesAsync();
}
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
[Fact]
public async Task AppendAndQuery()
{
var entry = new NotifyAuditEntryDocument
{
TenantId = "tenant-a",
Actor = "user@example.com",
Action = "create-rule",
EntityId = "rule-1",
EntityType = "rule",
Timestamp = DateTimeOffset.UtcNow,
Payload = new BsonDocument("ruleId", "rule-1")
};
await _repository.AppendAsync(entry);
var list = await _repository.QueryAsync("tenant-a", DateTimeOffset.UtcNow.AddMinutes(-5), 10);
Assert.Single(list);
Assert.Equal("create-rule", list[0].Action);
}
private static NotifyMongoInitializer CreateInitializer(NotifyMongoContext context)
{
var migrations = new INotifyMongoMigration[]
{
new EnsureNotifyCollectionsMigration(NullLogger<EnsureNotifyCollectionsMigration>.Instance),
new EnsureNotifyIndexesMigration()
};
var runner = new NotifyMongoMigrationRunner(context, migrations, NullLogger<NotifyMongoMigrationRunner>.Instance);
return new NotifyMongoInitializer(context, runner, NullLogger<NotifyMongoInitializer>.Instance);
}
}

View File

@@ -0,0 +1,77 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Mongo2Go;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Internal;
using StellaOps.Notify.Storage.Mongo.Migrations;
using StellaOps.Notify.Storage.Mongo.Options;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notify.Storage.Mongo.Tests.Repositories;
public sealed class NotifyChannelRepositoryTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner = MongoDbRunner.Start(singleNodeReplSet: true);
private readonly NotifyMongoContext _context;
private readonly NotifyMongoInitializer _initializer;
private readonly NotifyChannelRepository _repository;
public NotifyChannelRepositoryTests()
{
var options = Microsoft.Extensions.Options.Options.Create(new NotifyMongoOptions
{
ConnectionString = _runner.ConnectionString,
Database = "notify-channel-tests"
});
_context = new NotifyMongoContext(options, NullLogger<NotifyMongoContext>.Instance);
_initializer = CreateInitializer(_context);
_repository = new NotifyChannelRepository(_context);
}
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
public async Task InitializeAsync()
{
await _initializer.EnsureIndexesAsync();
}
[Fact]
public async Task UpsertChannelPersistsData()
{
var channel = NotifyChannel.Create(
channelId: "channel-1",
tenantId: "tenant-a",
name: "slack:sec",
type: NotifyChannelType.Slack,
config: NotifyChannelConfig.Create(secretRef: "ref://secret"));
await _repository.UpsertAsync(channel);
var fetched = await _repository.GetAsync("tenant-a", "channel-1");
Assert.NotNull(fetched);
Assert.Equal(channel.ChannelId, fetched!.ChannelId);
var listed = await _repository.ListAsync("tenant-a");
Assert.Single(listed);
await _repository.DeleteAsync("tenant-a", "channel-1");
Assert.Null(await _repository.GetAsync("tenant-a", "channel-1"));
}
private static NotifyMongoInitializer CreateInitializer(NotifyMongoContext context)
{
var migrations = new INotifyMongoMigration[]
{
new EnsureNotifyCollectionsMigration(NullLogger<EnsureNotifyCollectionsMigration>.Instance),
new EnsureNotifyIndexesMigration()
};
var runner = new NotifyMongoMigrationRunner(context, migrations, NullLogger<NotifyMongoMigrationRunner>.Instance);
return new NotifyMongoInitializer(context, runner, NullLogger<NotifyMongoInitializer>.Instance);
}
}

View File

@@ -0,0 +1,119 @@
using System;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Mongo2Go;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Internal;
using StellaOps.Notify.Storage.Mongo.Migrations;
using StellaOps.Notify.Storage.Mongo.Options;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notify.Storage.Mongo.Tests.Repositories;
public sealed class NotifyDeliveryRepositoryTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner = MongoDbRunner.Start(singleNodeReplSet: true);
private readonly NotifyMongoContext _context;
private readonly NotifyMongoInitializer _initializer;
private readonly NotifyDeliveryRepository _repository;
public NotifyDeliveryRepositoryTests()
{
var options = Microsoft.Extensions.Options.Options.Create(new NotifyMongoOptions
{
ConnectionString = _runner.ConnectionString,
Database = "notify-delivery-tests"
});
_context = new NotifyMongoContext(options, NullLogger<NotifyMongoContext>.Instance);
_initializer = CreateInitializer(_context);
_repository = new NotifyDeliveryRepository(_context);
}
public async Task InitializeAsync()
{
await _initializer.EnsureIndexesAsync();
}
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
[Fact]
public async Task AppendAndQueryWithPaging()
{
var now = DateTimeOffset.UtcNow;
var deliveries = new[]
{
NotifyDelivery.Create(
deliveryId: "delivery-1",
tenantId: "tenant-a",
ruleId: "rule-1",
actionId: "action-1",
eventId: Guid.NewGuid(),
kind: NotifyEventKinds.ScannerReportReady,
status: NotifyDeliveryStatus.Sent,
createdAt: now.AddMinutes(-2),
sentAt: now.AddMinutes(-2)),
NotifyDelivery.Create(
deliveryId: "delivery-2",
tenantId: "tenant-a",
ruleId: "rule-2",
actionId: "action-2",
eventId: Guid.NewGuid(),
kind: NotifyEventKinds.ScannerReportReady,
status: NotifyDeliveryStatus.Failed,
createdAt: now.AddMinutes(-1),
completedAt: now.AddMinutes(-1)),
NotifyDelivery.Create(
deliveryId: "delivery-3",
tenantId: "tenant-a",
ruleId: "rule-3",
actionId: "action-3",
eventId: Guid.NewGuid(),
kind: NotifyEventKinds.ScannerReportReady,
status: NotifyDeliveryStatus.Sent,
createdAt: now,
sentAt: now)
};
foreach (var delivery in deliveries)
{
await _repository.AppendAsync(delivery);
}
var fetched = await _repository.GetAsync("tenant-a", "delivery-3");
Assert.NotNull(fetched);
Assert.Equal("delivery-3", fetched!.DeliveryId);
var page1 = await _repository.QueryAsync("tenant-a", now.AddHours(-1), "sent", 1);
Assert.Single(page1.Items);
Assert.Equal("delivery-3", page1.Items[0].DeliveryId);
Assert.False(string.IsNullOrWhiteSpace(page1.ContinuationToken));
var page2 = await _repository.QueryAsync("tenant-a", now.AddHours(-1), "sent", 1, page1.ContinuationToken);
Assert.Single(page2.Items);
Assert.Equal("delivery-1", page2.Items[0].DeliveryId);
Assert.Null(page2.ContinuationToken);
}
[Fact]
public async Task QueryAsyncWithInvalidContinuationThrows()
{
await Assert.ThrowsAsync<ArgumentException>(() => _repository.QueryAsync("tenant-a", null, null, 10, "not-a-token"));
}
private static NotifyMongoInitializer CreateInitializer(NotifyMongoContext context)
{
var migrations = new INotifyMongoMigration[]
{
new EnsureNotifyCollectionsMigration(NullLogger<EnsureNotifyCollectionsMigration>.Instance),
new EnsureNotifyIndexesMigration()
};
var runner = new NotifyMongoMigrationRunner(context, migrations, NullLogger<NotifyMongoMigrationRunner>.Instance);
return new NotifyMongoInitializer(context, runner, NullLogger<NotifyMongoInitializer>.Instance);
}
}

View File

@@ -0,0 +1,79 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Mongo2Go;
using StellaOps.Notify.Storage.Mongo.Documents;
using StellaOps.Notify.Storage.Mongo.Internal;
using StellaOps.Notify.Storage.Mongo.Migrations;
using StellaOps.Notify.Storage.Mongo.Options;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notify.Storage.Mongo.Tests.Repositories;
public sealed class NotifyDigestRepositoryTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner = MongoDbRunner.Start(singleNodeReplSet: true);
private readonly NotifyMongoContext _context;
private readonly NotifyMongoInitializer _initializer;
private readonly NotifyDigestRepository _repository;
public NotifyDigestRepositoryTests()
{
var options = Microsoft.Extensions.Options.Options.Create(new NotifyMongoOptions
{
ConnectionString = _runner.ConnectionString,
Database = "notify-digest-tests"
});
_context = new NotifyMongoContext(options, NullLogger<NotifyMongoContext>.Instance);
_initializer = CreateInitializer(_context);
_repository = new NotifyDigestRepository(_context);
}
public async Task InitializeAsync()
{
await _initializer.EnsureIndexesAsync();
}
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
[Fact]
public async Task UpsertAndRemove()
{
var digest = new NotifyDigestDocument
{
TenantId = "tenant-a",
ActionKey = "action-1",
Window = "hourly",
OpenedAt = DateTimeOffset.UtcNow,
Status = "open",
Items = new List<NotifyDigestItemDocument>
{
new() { EventId = Guid.NewGuid().ToString() }
}
};
await _repository.UpsertAsync(digest);
var fetched = await _repository.GetAsync("tenant-a", "action-1");
Assert.NotNull(fetched);
Assert.Equal("action-1", fetched!.ActionKey);
await _repository.RemoveAsync("tenant-a", "action-1");
Assert.Null(await _repository.GetAsync("tenant-a", "action-1"));
}
private static NotifyMongoInitializer CreateInitializer(NotifyMongoContext context)
{
var migrations = new INotifyMongoMigration[]
{
new EnsureNotifyCollectionsMigration(NullLogger<EnsureNotifyCollectionsMigration>.Instance),
new EnsureNotifyIndexesMigration()
};
var runner = new NotifyMongoMigrationRunner(context, migrations, NullLogger<NotifyMongoMigrationRunner>.Instance);
return new NotifyMongoInitializer(context, runner, NullLogger<NotifyMongoInitializer>.Instance);
}
}

View File

@@ -0,0 +1,67 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Mongo2Go;
using StellaOps.Notify.Storage.Mongo.Internal;
using StellaOps.Notify.Storage.Mongo.Migrations;
using StellaOps.Notify.Storage.Mongo.Options;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notify.Storage.Mongo.Tests.Repositories;
public sealed class NotifyLockRepositoryTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner = MongoDbRunner.Start(singleNodeReplSet: true);
private readonly NotifyMongoContext _context;
private readonly NotifyMongoInitializer _initializer;
private readonly NotifyLockRepository _repository;
public NotifyLockRepositoryTests()
{
var options = Microsoft.Extensions.Options.Options.Create(new NotifyMongoOptions
{
ConnectionString = _runner.ConnectionString,
Database = "notify-lock-tests"
});
_context = new NotifyMongoContext(options, NullLogger<NotifyMongoContext>.Instance);
_initializer = CreateInitializer(_context);
_repository = new NotifyLockRepository(_context);
}
public async Task InitializeAsync()
{
await _initializer.EnsureIndexesAsync();
}
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
[Fact]
public async Task AcquireAndRelease()
{
var acquired = await _repository.TryAcquireAsync("tenant-a", "resource-1", "owner-1", TimeSpan.FromMinutes(1));
Assert.True(acquired);
var second = await _repository.TryAcquireAsync("tenant-a", "resource-1", "owner-2", TimeSpan.FromMinutes(1));
Assert.False(second);
await _repository.ReleaseAsync("tenant-a", "resource-1", "owner-1");
var third = await _repository.TryAcquireAsync("tenant-a", "resource-1", "owner-2", TimeSpan.FromMinutes(1));
Assert.True(third);
}
private static NotifyMongoInitializer CreateInitializer(NotifyMongoContext context)
{
var migrations = new INotifyMongoMigration[]
{
new EnsureNotifyCollectionsMigration(NullLogger<EnsureNotifyCollectionsMigration>.Instance),
new EnsureNotifyIndexesMigration()
};
var runner = new NotifyMongoMigrationRunner(context, migrations, NullLogger<NotifyMongoMigrationRunner>.Instance);
return new NotifyMongoInitializer(context, runner, NullLogger<NotifyMongoInitializer>.Instance);
}
}

View File

@@ -0,0 +1,79 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Mongo2Go;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Internal;
using StellaOps.Notify.Storage.Mongo.Migrations;
using StellaOps.Notify.Storage.Mongo.Options;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notify.Storage.Mongo.Tests.Repositories;
public sealed class NotifyRuleRepositoryTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner = MongoDbRunner.Start(singleNodeReplSet: true);
private readonly NotifyMongoContext _context;
private readonly NotifyMongoInitializer _initializer;
private readonly NotifyRuleRepository _repository;
public NotifyRuleRepositoryTests()
{
var options = Microsoft.Extensions.Options.Options.Create(new NotifyMongoOptions
{
ConnectionString = _runner.ConnectionString,
Database = "notify-rule-tests"
});
_context = new NotifyMongoContext(options, NullLogger<NotifyMongoContext>.Instance);
_initializer = CreateInitializer(_context);
_repository = new NotifyRuleRepository(_context);
}
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
public async Task InitializeAsync()
{
await _initializer.EnsureIndexesAsync();
}
[Fact]
public async Task UpsertRoundtripsData()
{
var rule = NotifyRule.Create(
ruleId: "rule-1",
tenantId: "tenant-a",
name: "Critical Alerts",
match: NotifyRuleMatch.Create(eventKinds: new[] { NotifyEventKinds.ScannerReportReady }),
actions: new[] { new NotifyRuleAction("action-1", "slack:sec") });
await _repository.UpsertAsync(rule);
var fetched = await _repository.GetAsync("tenant-a", "rule-1");
Assert.NotNull(fetched);
Assert.Equal(rule.RuleId, fetched!.RuleId);
Assert.Equal(rule.SchemaVersion, fetched.SchemaVersion);
var listed = await _repository.ListAsync("tenant-a");
Assert.Single(listed);
await _repository.DeleteAsync("tenant-a", "rule-1");
var deleted = await _repository.GetAsync("tenant-a", "rule-1");
Assert.Null(deleted);
}
private static NotifyMongoInitializer CreateInitializer(NotifyMongoContext context)
{
var migrations = new INotifyMongoMigration[]
{
new EnsureNotifyCollectionsMigration(NullLogger<EnsureNotifyCollectionsMigration>.Instance),
new EnsureNotifyIndexesMigration()
};
var runner = new NotifyMongoMigrationRunner(context, migrations, NullLogger<NotifyMongoMigrationRunner>.Instance);
return new NotifyMongoInitializer(context, runner, NullLogger<NotifyMongoInitializer>.Instance);
}
}

View File

@@ -0,0 +1,80 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Mongo2Go;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Internal;
using StellaOps.Notify.Storage.Mongo.Migrations;
using StellaOps.Notify.Storage.Mongo.Options;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notify.Storage.Mongo.Tests.Repositories;
public sealed class NotifyTemplateRepositoryTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner = MongoDbRunner.Start(singleNodeReplSet: true);
private readonly NotifyMongoContext _context;
private readonly NotifyMongoInitializer _initializer;
private readonly NotifyTemplateRepository _repository;
public NotifyTemplateRepositoryTests()
{
var options = Microsoft.Extensions.Options.Options.Create(new NotifyMongoOptions
{
ConnectionString = _runner.ConnectionString,
Database = "notify-template-tests"
});
_context = new NotifyMongoContext(options, NullLogger<NotifyMongoContext>.Instance);
_initializer = CreateInitializer(_context);
_repository = new NotifyTemplateRepository(_context);
}
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
public async Task InitializeAsync()
{
await _initializer.EnsureIndexesAsync();
}
[Fact]
public async Task UpsertTemplatePersistsData()
{
var template = NotifyTemplate.Create(
templateId: "template-1",
tenantId: "tenant-a",
channelType: NotifyChannelType.Slack,
key: "concise",
locale: "en-us",
body: "{{summary}}",
renderMode: NotifyTemplateRenderMode.Markdown,
format: NotifyDeliveryFormat.Slack);
await _repository.UpsertAsync(template);
var fetched = await _repository.GetAsync("tenant-a", "template-1");
Assert.NotNull(fetched);
Assert.Equal(template.TemplateId, fetched!.TemplateId);
var listed = await _repository.ListAsync("tenant-a");
Assert.Single(listed);
await _repository.DeleteAsync("tenant-a", "template-1");
Assert.Null(await _repository.GetAsync("tenant-a", "template-1"));
}
private static NotifyMongoInitializer CreateInitializer(NotifyMongoContext context)
{
var migrations = new INotifyMongoMigration[]
{
new EnsureNotifyCollectionsMigration(NullLogger<EnsureNotifyCollectionsMigration>.Instance),
new EnsureNotifyIndexesMigration()
};
var runner = new NotifyMongoMigrationRunner(context, migrations, NullLogger<NotifyMongoMigrationRunner>.Instance);
return new NotifyMongoInitializer(context, runner, NullLogger<NotifyMongoInitializer>.Instance);
}
}

View File

@@ -0,0 +1,35 @@
using System.Text.Json.Nodes;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Serialization;
namespace StellaOps.Notify.Storage.Mongo.Tests.Serialization;
public sealed class NotifyChannelDocumentMapperTests
{
[Fact]
public void RoundTripSampleChannelMaintainsCanonicalShape()
{
var sample = LoadSample("notify-channel@1.sample.json");
var node = JsonNode.Parse(sample) ?? throw new InvalidOperationException("Sample JSON null.");
var channel = NotifySchemaMigration.UpgradeChannel(node);
var bson = NotifyChannelDocumentMapper.ToBsonDocument(channel);
var restored = NotifyChannelDocumentMapper.FromBsonDocument(bson);
var canonical = NotifyCanonicalJsonSerializer.Serialize(restored);
var canonicalNode = JsonNode.Parse(canonical) ?? throw new InvalidOperationException("Canonical JSON null.");
Assert.True(JsonNode.DeepEquals(node, canonicalNode), "Canonical JSON should match sample document.");
}
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

@@ -0,0 +1,36 @@
using System.Text.Json.Nodes;
using MongoDB.Bson;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Serialization;
namespace StellaOps.Notify.Storage.Mongo.Tests.Serialization;
public sealed class NotifyRuleDocumentMapperTests
{
[Fact]
public void RoundTripSampleRuleMaintainsCanonicalShape()
{
var sample = LoadSample("notify-rule@1.sample.json");
var node = JsonNode.Parse(sample) ?? throw new InvalidOperationException("Sample JSON null.");
var rule = NotifySchemaMigration.UpgradeRule(node);
var bson = NotifyRuleDocumentMapper.ToBsonDocument(rule);
var restored = NotifyRuleDocumentMapper.FromBsonDocument(bson);
var canonical = NotifyCanonicalJsonSerializer.Serialize(restored);
var canonicalNode = JsonNode.Parse(canonical) ?? throw new InvalidOperationException("Canonical JSON null.");
Assert.True(JsonNode.DeepEquals(node, canonicalNode), "Canonical JSON should match sample document.");
}
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

@@ -0,0 +1,35 @@
using System.Text.Json.Nodes;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Serialization;
namespace StellaOps.Notify.Storage.Mongo.Tests.Serialization;
public sealed class NotifyTemplateDocumentMapperTests
{
[Fact]
public void RoundTripSampleTemplateMaintainsCanonicalShape()
{
var sample = LoadSample("notify-template@1.sample.json");
var node = JsonNode.Parse(sample) ?? throw new InvalidOperationException("Sample JSON null.");
var template = NotifySchemaMigration.UpgradeTemplate(node);
var bson = NotifyTemplateDocumentMapper.ToBsonDocument(template);
var restored = NotifyTemplateDocumentMapper.FromBsonDocument(bson);
var canonical = NotifyCanonicalJsonSerializer.Serialize(restored);
var canonicalNode = JsonNode.Parse(canonical) ?? throw new InvalidOperationException("Canonical JSON null.");
Assert.True(JsonNode.DeepEquals(node, canonicalNode), "Canonical JSON should match sample document.");
}
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

@@ -0,0 +1,29 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Storage.Mongo/StellaOps.Notify.Storage.Mongo.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Mongo2Go" Version="3.1.3" />
<PackageReference Include="MongoDB.Bson" Version="3.5.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<None Include="../../docs/notify/samples/*.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,417 @@
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.IdentityModel.Tokens;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Notify.Engine;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.WebService.Tests;
public sealed class CrudEndpointsTests : IClassFixture<WebApplicationFactory<Program>>, IAsyncLifetime
{
private const string SigningKey = "super-secret-test-key-1234567890";
private const string Issuer = "test-issuer";
private const string Audience = "notify";
private readonly WebApplicationFactory<Program> _factory;
private readonly string _adminToken;
private readonly string _readToken;
public CrudEndpointsTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.UseSetting("notify:storage:driver", "memory");
builder.UseSetting("notify:authority:enabled", "false");
builder.UseSetting("notify:authority:developmentSigningKey", SigningKey);
builder.UseSetting("notify:authority:issuer", Issuer);
builder.UseSetting("notify:authority:audiences:0", Audience);
builder.UseSetting("notify:authority:adminScope", "notify.admin");
builder.UseSetting("notify:authority:readScope", "notify.read");
builder.UseSetting("notify:telemetry:enableRequestLogging", "false");
builder.UseSetting("notify:api:rateLimits:testSend:tokenLimit", "10");
builder.UseSetting("notify:api:rateLimits:testSend:tokensPerPeriod", "10");
builder.UseSetting("notify:api:rateLimits:testSend:queueLimit", "5");
builder.UseSetting("notify:api:rateLimits:deliveryHistory:tokenLimit", "30");
builder.UseSetting("notify:api:rateLimits:deliveryHistory:tokensPerPeriod", "30");
builder.UseSetting("notify:api:rateLimits:deliveryHistory:queueLimit", "10");
});
_adminToken = CreateToken("notify.admin");
_readToken = CreateToken("notify.read");
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task RuleCrudLifecycle()
{
var client = _factory.CreateClient();
var payload = LoadSample("notify-rule@1.sample.json");
payload["ruleId"] = "rule-web";
payload["tenantId"] = "tenant-web";
payload["actions"]!.AsArray()[0]! ["actionId"] = "action-web";
await PostAsync(client, "/api/v1/notify/rules", payload);
var list = await GetJsonArrayAsync(client, "/api/v1/notify/rules", useAdminToken: false);
Assert.Equal("rule-web", list?[0]? ["ruleId"]?.GetValue<string>());
var single = await GetJsonObjectAsync(client, "/api/v1/notify/rules/rule-web", useAdminToken: false);
Assert.Equal("tenant-web", single? ["tenantId"]?.GetValue<string>());
await DeleteAsync(client, "/api/v1/notify/rules/rule-web");
var afterDelete = await SendAsync(client, HttpMethod.Get, "/api/v1/notify/rules/rule-web", useAdminToken: false);
Assert.Equal(HttpStatusCode.NotFound, afterDelete.StatusCode);
}
[Fact]
public async Task ChannelTemplateDeliveryAndAuditFlows()
{
var client = _factory.CreateClient();
var channelPayload = LoadSample("notify-channel@1.sample.json");
channelPayload["channelId"] = "channel-web";
channelPayload["tenantId"] = "tenant-web";
await PostAsync(client, "/api/v1/notify/channels", channelPayload);
var templatePayload = LoadSample("notify-template@1.sample.json");
templatePayload["templateId"] = "template-web";
templatePayload["tenantId"] = "tenant-web";
await PostAsync(client, "/api/v1/notify/templates", templatePayload);
var delivery = NotifyDelivery.Create(
deliveryId: "delivery-web",
tenantId: "tenant-web",
ruleId: "rule-web",
actionId: "channel-web",
eventId: Guid.NewGuid(),
kind: NotifyEventKinds.ScannerReportReady,
status: NotifyDeliveryStatus.Sent,
createdAt: DateTimeOffset.UtcNow,
sentAt: DateTimeOffset.UtcNow);
var deliveryNode = JsonNode.Parse(NotifyCanonicalJsonSerializer.Serialize(delivery))!;
await PostAsync(client, "/api/v1/notify/deliveries", deliveryNode);
var deliveriesEnvelope = await GetJsonObjectAsync(client, "/api/v1/notify/deliveries?limit=10", useAdminToken: false);
Assert.NotNull(deliveriesEnvelope);
Assert.Equal(1, deliveriesEnvelope? ["count"]?.GetValue<int>());
Assert.Null(deliveriesEnvelope? ["continuationToken"]?.GetValue<string>());
var deliveries = deliveriesEnvelope? ["items"] as JsonArray;
Assert.NotNull(deliveries);
Assert.NotEmpty(deliveries!.OfType<JsonNode>());
var digestNode = new JsonObject
{
["tenantId"] = "tenant-web",
["actionKey"] = "channel-web",
["window"] = "hourly",
["openedAt"] = DateTimeOffset.UtcNow.ToString("O"),
["status"] = "open",
["items"] = new JsonArray()
};
await PostAsync(client, "/api/v1/notify/digests", digestNode);
var digest = await GetJsonObjectAsync(client, "/api/v1/notify/digests/channel-web", useAdminToken: false);
Assert.Equal("channel-web", digest? ["actionKey"]?.GetValue<string>());
var auditPayload = JsonNode.Parse("""
{
"action": "create-rule",
"entityType": "rule",
"entityId": "rule-web",
"payload": {"ruleId": "rule-web"}
}
""")!;
await PostAsync(client, "/api/v1/notify/audit", auditPayload);
var audits = await GetJsonArrayAsync(client, "/api/v1/notify/audit", useAdminToken: false);
Assert.NotNull(audits);
Assert.Contains(audits!.OfType<JsonObject>(), entry => entry?["action"]?.GetValue<string>() == "create-rule");
await DeleteAsync(client, "/api/v1/notify/digests/channel-web");
var digestAfterDelete = await SendAsync(client, HttpMethod.Get, "/api/v1/notify/digests/channel-web", useAdminToken: false);
Assert.Equal(HttpStatusCode.NotFound, digestAfterDelete.StatusCode);
}
[Fact]
public async Task LockEndpointsAllowAcquireAndRelease()
{
var client = _factory.CreateClient();
var acquirePayload = JsonNode.Parse("""
{
"resource": "workers",
"owner": "worker-1",
"ttlSeconds": 30
}
""")!;
var acquireResponse = await PostAsync(client, "/api/v1/notify/locks/acquire", acquirePayload);
var acquireContent = JsonNode.Parse(await acquireResponse.Content.ReadAsStringAsync());
Assert.True(acquireContent? ["acquired"]?.GetValue<bool>());
await PostAsync(client, "/api/v1/notify/locks/release", JsonNode.Parse("""
{
"resource": "workers",
"owner": "worker-1"
}
""")!);
var secondAcquire = await PostAsync(client, "/api/v1/notify/locks/acquire", acquirePayload);
var secondContent = JsonNode.Parse(await secondAcquire.Content.ReadAsStringAsync());
Assert.True(secondContent? ["acquired"]?.GetValue<bool>());
}
[Fact]
public async Task ChannelTestSendReturnsPreview()
{
var client = _factory.CreateClient();
var channelPayload = LoadSample("notify-channel@1.sample.json");
channelPayload["channelId"] = "channel-test";
channelPayload["tenantId"] = "tenant-web";
channelPayload["config"]! ["target"] = "#ops-alerts";
await PostAsync(client, "/api/v1/notify/channels", channelPayload);
var payload = JsonNode.Parse("""
{
"target": "#ops-alerts",
"title": "Smoke test",
"body": "Sample body"
}
""")!;
var response = await PostAsync(client, "/api/v1/notify/channels/channel-test/test", payload);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
var json = JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsObject();
Assert.Equal("tenant-web", json["tenantId"]?.GetValue<string>());
Assert.Equal("channel-test", json["channelId"]?.GetValue<string>());
Assert.NotNull(json["queuedAt"]);
Assert.NotNull(json["traceId"]);
var preview = json["preview"]?.AsObject();
Assert.NotNull(preview);
Assert.Equal("#ops-alerts", preview? ["target"]?.GetValue<string>());
Assert.Equal("Smoke test", preview? ["title"]?.GetValue<string>());
Assert.Equal("Sample body", preview? ["body"]?.GetValue<string>());
var expectedHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes("Sample body"))).ToLowerInvariant();
Assert.Equal(expectedHash, preview? ["bodyHash"]?.GetValue<string>());
var metadata = json["metadata"] as JsonObject;
Assert.NotNull(metadata);
Assert.Equal("#ops-alerts", metadata?["target"]?.GetValue<string>());
Assert.Equal("slack", metadata?["channelType"]?.GetValue<string>());
Assert.Equal("fallback", metadata?["previewProvider"]?.GetValue<string>());
Assert.Equal(json["traceId"]?.GetValue<string>(), metadata?["traceId"]?.GetValue<string>());
}
[Fact]
public async Task ChannelTestSendHonoursRateLimit()
{
using var limitedFactory = _factory.WithWebHostBuilder(builder =>
{
builder.UseSetting("notify:api:rateLimits:testSend:tokenLimit", "1");
builder.UseSetting("notify:api:rateLimits:testSend:tokensPerPeriod", "1");
builder.UseSetting("notify:api:rateLimits:testSend:queueLimit", "0");
});
var client = limitedFactory.CreateClient();
var channelPayload = LoadSample("notify-channel@1.sample.json");
channelPayload["channelId"] = "channel-rate-limit";
channelPayload["tenantId"] = "tenant-web";
channelPayload["config"]! ["target"] = "#ops-alerts";
await PostAsync(client, "/api/v1/notify/channels", channelPayload);
var payload = JsonNode.Parse("""
{
"body": "First"
}
""")!;
var first = await PostAsync(client, "/api/v1/notify/channels/channel-rate-limit/test", payload);
Assert.Equal(HttpStatusCode.Accepted, first.StatusCode);
var secondRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/notify/channels/channel-rate-limit/test")
{
Content = new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json")
};
var second = await SendAsync(client, secondRequest);
Assert.Equal(HttpStatusCode.TooManyRequests, second.StatusCode);
Assert.NotNull(second.Headers.RetryAfter);
}
[Fact]
public async Task ChannelTestSendUsesRegisteredProvider()
{
var providerName = typeof(FakeSlackTestProvider).FullName!;
using var providerFactory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.AddSingleton<INotifyChannelTestProvider, FakeSlackTestProvider>();
});
});
var client = providerFactory.CreateClient();
var channelPayload = LoadSample("notify-channel@1.sample.json");
channelPayload["channelId"] = "channel-provider";
channelPayload["tenantId"] = "tenant-web";
channelPayload["config"]! ["target"] = "#ops-alerts";
await PostAsync(client, "/api/v1/notify/channels", channelPayload);
var payload = JsonNode.Parse("""
{
"target": "#ops-alerts",
"title": "Provider Title",
"summary": "Provider Summary"
}
""")!;
var response = await PostAsync(client, "/api/v1/notify/channels/channel-provider/test", payload);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
var json = JsonNode.Parse(await response.Content.ReadAsStringAsync())!.AsObject();
var preview = json["preview"]?.AsObject();
Assert.NotNull(preview);
Assert.Equal("#ops-alerts", preview?["target"]?.GetValue<string>());
Assert.Equal("Provider Title", preview?["title"]?.GetValue<string>());
Assert.Equal("{\"provider\":\"fake\"}", preview?["body"]?.GetValue<string>());
var metadata = json["metadata"]?.AsObject();
Assert.NotNull(metadata);
Assert.Equal(providerName, metadata?["previewProvider"]?.GetValue<string>());
Assert.Equal("fake-provider", metadata?["provider.name"]?.GetValue<string>());
}
private sealed class FakeSlackTestProvider : INotifyChannelTestProvider
{
public NotifyChannelType ChannelType => NotifyChannelType.Slack;
public Task<ChannelTestPreviewResult> BuildPreviewAsync(ChannelTestPreviewContext context, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var body = "{\"provider\":\"fake\"}";
var preview = NotifyDeliveryRendered.Create(
NotifyChannelType.Slack,
NotifyDeliveryFormat.Slack,
context.Target,
context.Request.Title ?? "Provider Title",
body,
context.Request.Summary ?? "Provider Summary",
context.Request.TextBody,
context.Request.Locale,
ChannelTestPreviewUtilities.ComputeBodyHash(body),
context.Request.Attachments);
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["provider.name"] = "fake-provider"
};
return Task.FromResult(new ChannelTestPreviewResult(preview, metadata));
}
}
private static JsonNode LoadSample(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)) ?? throw new InvalidOperationException("Sample JSON null.");
}
private async Task<JsonArray?> GetJsonArrayAsync(HttpClient client, string path, bool useAdminToken)
{
var response = await SendAsync(client, HttpMethod.Get, path, useAdminToken);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
return JsonNode.Parse(content) as JsonArray;
}
private async Task<JsonObject?> GetJsonObjectAsync(HttpClient client, string path, bool useAdminToken)
{
var response = await SendAsync(client, HttpMethod.Get, path, useAdminToken);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
return JsonNode.Parse(content) as JsonObject;
}
private async Task<HttpResponseMessage> PostAsync(HttpClient client, string path, JsonNode payload, bool useAdminToken = true)
{
var request = new HttpRequestMessage(HttpMethod.Post, path)
{
Content = new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json")
};
var response = await SendAsync(client, request, useAdminToken);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync();
throw new InvalidOperationException($"Request to {path} failed with {(int)response.StatusCode} {response.StatusCode}: {body}");
}
return response;
}
private Task<HttpResponseMessage> PostAsync(HttpClient client, string path, JsonNode payload)
=> PostAsync(client, path, payload, useAdminToken: true);
private async Task DeleteAsync(HttpClient client, string path)
{
var response = await SendAsync(client, HttpMethod.Delete, path);
response.EnsureSuccessStatusCode();
}
private Task<HttpResponseMessage> SendAsync(HttpClient client, HttpMethod method, string path, bool useAdminToken = true)
=> SendAsync(client, new HttpRequestMessage(method, path), useAdminToken);
private Task<HttpResponseMessage> SendAsync(HttpClient client, HttpRequestMessage request, bool useAdminToken = true)
{
request.Headers.Add("X-StellaOps-Tenant", "tenant-web");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", useAdminToken ? _adminToken : _readToken);
return client.SendAsync(request);
}
private static string CreateToken(string scope)
{
var handler = new JwtSecurityTokenHandler();
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SigningKey));
var descriptor = new SecurityTokenDescriptor
{
Issuer = Issuer,
Audience = Audience,
Expires = DateTime.UtcNow.AddMinutes(10),
SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256),
Subject = new System.Security.Claims.ClaimsIdentity(new[]
{
new System.Security.Claims.Claim("scope", scope),
new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, "integration-test")
})
};
var token = handler.CreateToken(descriptor);
return handler.WriteToken(token);
}
}

View File

@@ -0,0 +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));
}
}

View File

@@ -0,0 +1,19 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
<ProjectReference Include="../../StellaOps.Notify.WebService/StellaOps.Notify.WebService.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="../../docs/notify/samples/*.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +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;
}
}
}

View File

@@ -0,0 +1,27 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Notify.Worker/StellaOps.Notify.Worker.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Queue/StellaOps.Notify.Queue.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
</ItemGroup>
</Project>